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] {Inline docs}[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: + * + */ +$(document).ready(function() { + var recent_autosave = false; + var autosave_timers = {}; + var input_debounce_timers = {}; + var fields_dirty = {}; // Track if field has changed since focus + + // Determine if an element should save immediately (no debounce) + // Selects, checkboxes, radios, and hidden fields save on change + function isImmediateSaveElement(element) { + var tagName = element.tagName.toLowerCase(); + var inputType = element.type ? element.type.toLowerCase() : ''; + return tagName === 'select' || + inputType === 'checkbox' || + inputType === 'radio' || + inputType === 'hidden'; + } + + function updateFieldVisualState(field, state) { + // Remove all autosave-related classes + field.removeClass('border-gray-300 border-yellow-400 border-green-400 border-red-400'); + + // Find the status element in the same container (works with both dashboard and content edit pages) + var statusElement = field.closest('.relative').find('.js-autosave-status'); + var statusText = statusElement.find('.js-status-text'); + + switch(state) { + case 'saving': + field.addClass('border-yellow-400'); + statusElement.removeClass('hidden text-gray-400 text-green-600 text-red-600').addClass('text-yellow-600'); + statusText.text('Saving...'); + break; + case 'saved': + field.addClass('border-green-400'); + statusElement.removeClass('hidden text-gray-400 text-yellow-600 text-red-600').addClass('text-green-600'); + statusText.text('✓ Saved'); + break; + case 'error': + field.addClass('border-red-400'); + statusElement.removeClass('hidden text-gray-400 text-yellow-600 text-green-600').addClass('text-red-600'); + statusText.text('✗ Error'); + break; + default: + field.addClass('border-gray-300'); + statusElement.addClass('hidden').removeClass('text-yellow-600 text-green-600 text-red-600').addClass('text-gray-400'); + statusText.text(''); + break; + } + } + + function performAutosave(field) { + var content_form = field.closest('form'); + var fieldId = field.attr('id') || field.attr('name') || 'unknown'; + + if (content_form.length) { + recent_autosave = true; + setTimeout(() => recent_autosave = false, 1000); + + var form_data = content_form.serialize(); + form_data += "&authenticity_token=" + encodeURIComponent($('meta[name="csrf-token"]').attr('content')); + + var saveIndicator = field.siblings('.js-save-indicator'); + + console.log('Autosaving field...'); + + // Update visual state to saving (textarea border turns yellow) + updateFieldVisualState(field, 'saving'); + saveIndicator.addClass('hidden'); + + $.ajax({ + url: content_form.attr('action') + '.json', + type: content_form.attr('method').toUpperCase(), + data: form_data, + success: function(response) { + console.log('Autosave successful'); + updateFieldVisualState(field, 'saved'); + + // Reset dirty flag so we don't re-save unchanged content + fields_dirty[fieldId] = false; + + // Dispatch autosave:success event for other listeners (e.g., unsaved changes warning) + var event = new CustomEvent('autosave:success', { + detail: { field: field[0], response: response } + }); + document.dispatchEvent(event); + + // Show "Saved!" indicator + saveIndicator.removeClass('hidden'); + window.showToast('Saved!', 'success'); + + // Reset back to default coloring and hide indicator after 3 seconds + setTimeout(function () { + updateFieldVisualState(field, 'default'); + saveIndicator.addClass('hidden'); + }, 3000); + }, + error: function(xhr, status, error) { + console.log('Autosave error:', error); + updateFieldVisualState(field, 'error'); + + // Show error indicator with different styling + saveIndicator.find('span').removeClass('bg-green-100 text-green-800').addClass('bg-red-100 text-red-800'); + saveIndicator.find('span').text('✗ Error saving'); + saveIndicator.removeClass('hidden'); + window.showToast('Error saving', 'error'); + + // Reset error state after 5 seconds + setTimeout(function() { + updateFieldVisualState(field, 'default'); + saveIndicator.addClass('hidden'); + // Reset indicator styling for next use + saveIndicator.find('span').removeClass('bg-red-100 text-red-800').addClass('bg-green-100 text-green-800'); + saveIndicator.find('span').text('✓ Saved!'); + }, 5000); + } + }); + } else { + console.log('Error: no form found for autosave'); + window.showToast('Error: No form found', 'error'); + } + } + + function setupAutosaveTimer(field) { + var fieldId = field.attr('id') || field.attr('name') || Math.random().toString(36); + + // Clear existing timer for this field + if (autosave_timers[fieldId]) { + clearTimeout(autosave_timers[fieldId]); + } + + // Don't show "Saving..." yet - we're just queuing the save for later. + // The visual feedback will be shown when performAutosave() actually starts. + + // Set new timer for 10 seconds + autosave_timers[fieldId] = setTimeout(function() { + if (field.is(':focus') && fields_dirty[fieldId]) { + performAutosave(field); + } + delete autosave_timers[fieldId]; + }, 10000); + } + + function debounceInput(field, callback, delay) { + var fieldId = field.attr('id') || field.attr('name') || Math.random().toString(36); + + if (input_debounce_timers[fieldId]) { + clearTimeout(input_debounce_timers[fieldId]); + } + + input_debounce_timers[fieldId] = setTimeout(function() { + callback(); + delete input_debounce_timers[fieldId]; + }, delay); + } + + // Autosave for text fields (with debouncing) + // Use delegated event binding to work with dynamically added elements and survive page refresh timing issues + $(document).on('input', '.js-autosave', function() { + var field = $(this); + var fieldId = field.attr('id') || field.attr('name') || 'unknown'; + + // Mark field as dirty (has changes) + fields_dirty[fieldId] = true; + + // Debounce input to avoid setting up too many timers during rapid typing + debounceInput(field, function() { + setupAutosaveTimer(field); + }, 300); // 300ms debounce + }); + + $(document).on('blur', '.js-autosave', function() { + var field = $(this); + var fieldId = field.attr('id') || field.attr('name') || 'unknown'; + + // Clear any pending timers since we're handling blur + if (autosave_timers[fieldId]) { + clearTimeout(autosave_timers[fieldId]); + delete autosave_timers[fieldId]; + } + if (input_debounce_timers[fieldId]) { + clearTimeout(input_debounce_timers[fieldId]); + delete input_debounce_timers[fieldId]; + } + + // Only autosave if field is dirty (user made changes) + if (fields_dirty[fieldId]) { + performAutosave(field); + } else { + // Reset visual state if no changes to save + updateFieldVisualState(field, 'default'); + } + }); + + // Focus event to reset dirty flag and any error states + $(document).on('focus', '.js-autosave', function() { + var field = $(this); + var fieldId = field.attr('id') || field.attr('name') || 'unknown'; + var saveIndicator = field.siblings('.js-save-indicator'); + + // Reset dirty flag on focus (will be set true by input event if user types) + fields_dirty[fieldId] = false; + + // Hide any existing save indicators when user starts editing again + if (!saveIndicator.hasClass('hidden')) { + setTimeout(function() { + saveIndicator.addClass('hidden'); + }, 500); + } + }); + + // Immediate save on change for selects, checkboxes, radios, hidden fields + $(document).on('change', '.js-autosave', function() { + var field = $(this); + if (isImmediateSaveElement(this)) { + performAutosave(field); + } + }); + + // Click-to-submit handler (migrated from autosave.js) + $(document).on('click', '.submit-closest-form-on-click', function() { + $(this).closest('form').submit(); + }); + + // Clear timers on page unload to prevent memory leaks + window.addEventListener('beforeunload', function() { + Object.keys(autosave_timers).forEach(function(fieldId) { + clearTimeout(autosave_timers[fieldId]); + }); + Object.keys(input_debounce_timers).forEach(function(fieldId) { + clearTimeout(input_debounce_timers[fieldId]); + }); + }); +}); \ No newline at end of file diff --git a/app/assets/javascripts/keyboardControls.js b/app/assets/javascripts/keyboardControls.js index 6269d7406..bd2c49945 100644 --- a/app/assets/javascripts/keyboardControls.js +++ b/app/assets/javascripts/keyboardControls.js @@ -24,6 +24,12 @@ function keyboardControlManager ( keyboardControls ) { return; } + // must ignore if currently focused in a contenteditable element (e.g. MediumEditor) + var activeEl = document.activeElement; + if (activeEl && (activeEl.isContentEditable || activeEl.closest('[contenteditable="true"]'))) { + return; + } + // if not modifier, continue stackManager.add({ "key" : event.keyCode, diff --git a/app/assets/javascripts/page_tags.js b/app/assets/javascripts/page_tags.js deleted file mode 100644 index 64f6cf8a1..000000000 --- a/app/assets/javascripts/page_tags.js +++ /dev/null @@ -1,9 +0,0 @@ -$(document).ready(function () { - $('.js-add-tag').click(function() { - var clicked_tag = $(this).find('.badge').data('badge-caption'); - var chips_reference = $(this).closest('.input-field').find('.chips'); - - M.Chips.getInstance(chips_reference).addChip({ tag: clicked_tag }); - return false; - }); -}); diff --git a/app/assets/javascripts/smoothscrolling.js b/app/assets/javascripts/smoothscrolling.js new file mode 100644 index 000000000..a559fd6c1 --- /dev/null +++ b/app/assets/javascripts/smoothscrolling.js @@ -0,0 +1,15 @@ +document.querySelectorAll('a[href^="#"]').forEach((anchor) => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + + const target = document.querySelector(this.getAttribute('href')); + + // Check if the target element exists + if (target) { + window.scrollTo({ + top: target.offsetTop, + behavior: 'smooth', + }); + } + }); +}); \ No newline at end of file diff --git a/app/assets/javascripts/api_docs.coffee b/app/assets/javascripts/styleguide.coffee similarity index 100% rename from app/assets/javascripts/api_docs.coffee rename to app/assets/javascripts/styleguide.coffee diff --git a/app/assets/javascripts/tag_input_component.js b/app/assets/javascripts/tag_input_component.js new file mode 100644 index 000000000..664026c39 --- /dev/null +++ b/app/assets/javascripts/tag_input_component.js @@ -0,0 +1,167 @@ +/** + * Tag Input Component + * + * A reusable Alpine.js component for tag input with autocomplete functionality. + * Used in timeline overview, inspector panel, and event cards. + * + * Usage: + * x-data="tagInputComponent({ + * initialTags: ['tag1', 'tag2'], + * suggestionsUrl: '/api/tag_suggestions', + * onAdd: (tag) => console.log('Added:', tag), + * onRemove: (tag) => console.log('Removed:', tag) + * })" + */ + +function tagInputComponent(options = {}) { + const { + initialTags = [], + suggestionsUrl = null, + staticSuggestions = [], + onAdd = null, + onRemove = null, + maxSuggestions = 10 + } = options; + + return { + currentTags: [...initialTags], + availableSuggestions: [...staticSuggestions], + tagInput: '', + showSuggestions: false, + loadingTags: new Set(), + + async init() { + if (suggestionsUrl) { + await this.loadSuggestions(); + } + }, + + async loadSuggestions() { + try { + const response = await fetch(suggestionsUrl); + if (response.ok) { + const data = await response.json(); + this.availableSuggestions = data.suggestions || data || []; + } + } catch (error) { + console.error('Failed to load tag suggestions:', error); + } + }, + + get filteredSuggestions() { + const input = (this.tagInput || '').toLowerCase().trim(); + + return this.availableSuggestions + .filter(tag => { + const matchesInput = !input || tag.toLowerCase().includes(input); + const notAlreadyAdded = !this.currentTags.includes(tag); + return matchesInput && notAlreadyAdded; + }) + .slice(0, maxSuggestions); + }, + + isTagLoading(tag) { + return this.loadingTags.has(tag); + }, + + async addTag(tagName) { + if (!tagName || !tagName.trim()) return; + + const cleanTag = tagName.trim(); + + // Don't add duplicates + if (this.currentTags.includes(cleanTag)) { + this.tagInput = ''; + this.showSuggestions = false; + return; + } + + // Add to current tags immediately for UI responsiveness + this.currentTags.push(cleanTag); + this.loadingTags.add(cleanTag); + + // Clear input and hide suggestions + this.tagInput = ''; + this.showSuggestions = false; + + // Call the onAdd callback if provided + if (typeof onAdd === 'function') { + try { + await onAdd(cleanTag, this.currentTags); + } catch (error) { + console.error('Error in onAdd callback:', error); + // Rollback on error + this.currentTags = this.currentTags.filter(t => t !== cleanTag); + } + } + + this.loadingTags.delete(cleanTag); + }, + + async removeTag(tagName) { + if (!tagName) return; + + // Mark as loading + this.loadingTags.add(tagName); + + // Remove from current tags + const previousTags = [...this.currentTags]; + this.currentTags = this.currentTags.filter(tag => tag !== tagName); + + // Call the onRemove callback if provided + if (typeof onRemove === 'function') { + try { + await onRemove(tagName, this.currentTags); + } catch (error) { + console.error('Error in onRemove callback:', error); + // Rollback on error + this.currentTags = previousTags; + } + } + + this.loadingTags.delete(tagName); + }, + + selectSuggestion(tag) { + this.addTag(tag); + }, + + handleKeydown(event) { + switch (event.key) { + case 'Escape': + this.showSuggestions = false; + this.tagInput = ''; + break; + case 'Enter': + event.preventDefault(); + this.addTag(this.tagInput); + break; + case ',': + event.preventDefault(); + this.addTag(this.tagInput); + break; + } + }, + + handleFocus() { + this.showSuggestions = true; + }, + + handleClickAway() { + this.showSuggestions = false; + }, + + // Utility method to update tags externally + setTags(tags) { + this.currentTags = [...tags]; + }, + + // Utility method to get current tags + getTags() { + return [...this.currentTags]; + } + }; +} + +// Make it available globally for Alpine.js +window.tagInputComponent = tagInputComponent; diff --git a/app/assets/javascripts/tailwind_initialization.js b/app/assets/javascripts/tailwind_initialization.js new file mode 100644 index 000000000..1f45dc76e --- /dev/null +++ b/app/assets/javascripts/tailwind_initialization.js @@ -0,0 +1,42 @@ +//# Initialization for Tailwind pages +//# This file contains initialization code for Tailwind pages without MaterializeCSS + +if (!window.Notebook) { window.Notebook = {}; } +Notebook.tailwindInit = function() { + // Initialize non-MaterializeCSS components here + + // Character counters for textareas and inputs with maxlength + document.querySelectorAll('[maxlength]').forEach(function(element) { + const maxLength = element.getAttribute('maxlength'); + const counter = document.createElement('div'); + counter.className = 'text-xs text-right text-gray-500 mt-1'; + counter.innerHTML = `${element.value.length}/${maxLength}`; + element.parentNode.appendChild(counter); + + element.addEventListener('input', function() { + const currentLength = this.value.length; + const counterElement = this.parentNode.querySelector('.current-length'); + counterElement.textContent = currentLength; + + if (currentLength > maxLength) { + counterElement.classList.add('text-red-500'); + } else { + counterElement.classList.remove('text-red-500'); + } + }); + }); + + // Tooltips are handled via CSS-only (see application.css) + // Use classes: tooltip-left, tooltip-right, tooltip-top, tooltip-bottom + // With attribute: data-tooltip="Your tooltip text" +}; + +// Initialize on DOM ready for Tailwind pages +document.addEventListener('DOMContentLoaded', function() { + // Only run on Tailwind pages + if (document.body && document.body.getAttribute('data-in-app') === 'true') { + if (window.Notebook && window.Notebook.tailwindInit) { + Notebook.tailwindInit(); + } + } +}); \ No newline at end of file diff --git a/app/assets/javascripts/timeline-editor.js b/app/assets/javascripts/timeline-editor.js index 21cd1529d..92addcf63 100644 --- a/app/assets/javascripts/timeline-editor.js +++ b/app/assets/javascripts/timeline-editor.js @@ -1,152 +1,1052 @@ -$(document).ready(function () { - function get_event_id_from_url(url) { - return url.split('/')[4]; +// Auto-scroll configuration for drag and drop +const AUTO_SCROLL_CONFIG = { + edgeZone: 100, // Pixels from viewport edge to trigger scroll + maxScrollSpeed: 20, // Max pixels per frame when at edge + minScrollSpeed: 2 // Min pixels per frame when entering zone +}; + +let autoScrollInterval = null; + +// Start auto-scrolling in the given direction at the given speed +function startAutoScroll(direction, speed) { + stopAutoScroll(); + function scroll() { + window.scrollBy(0, direction * speed); + autoScrollInterval = requestAnimationFrame(scroll); } + autoScrollInterval = requestAnimationFrame(scroll); +} - $('.js-trigger-autosave-on-change').change(function () { - $(this).closest('.autosave-form').submit(); - M.toast({ - html: "Autosaving..." - }); +// Stop any active auto-scrolling +function stopAutoScroll() { + if (autoScrollInterval) { + cancelAnimationFrame(autoScrollInterval); + autoScrollInterval = null; + } +} + +// Calculate scroll speed based on distance from viewport edge +function calculateScrollSpeed(distanceFromEdge) { + const { edgeZone, maxScrollSpeed, minScrollSpeed } = AUTO_SCROLL_CONFIG; + const normalized = 1 - (distanceFromEdge / edgeZone); + return minScrollSpeed + (maxScrollSpeed - minScrollSpeed) * normalized; +} + +// Initialize timeline events sortable functionality +function initTimelineEventsSortable() { + // Check if jQuery UI is available + if (typeof $ === 'undefined' || !$.fn.sortable) { + console.error('jQuery UI Sortable not found - drag and drop disabled'); + return; + } + + const eventsContainer = $('.timeline-events-container'); + if (!eventsContainer.length) return; + + eventsContainer.sortable({ + items: '.timeline-event-container:not(.timeline-event-template)', + handle: '.timeline-event-drag-handle', + placeholder: 'timeline-event-placeholder', + cursor: 'grabbing', + opacity: 0.8, + tolerance: 'pointer', + distance: 10, // Prevent accidental drags + helper: 'clone', + start: function(event, ui) { + // Add visual feedback + ui.item.addClass('timeline-event-dragging'); + ui.placeholder.addClass('timeline-event-placeholder'); + + // Store original position for rollback if needed (count only event containers) + const allEvents = $('.timeline-events-container .timeline-event-container:not(.timeline-event-template)'); + ui.item.data('original-position', allEvents.index(ui.item)); + }, + sort: function(event, ui) { + // Auto-scroll when dragging near viewport edges + const helperRect = ui.helper[0].getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const distanceFromTop = helperRect.top; + const distanceFromBottom = viewportHeight - helperRect.bottom; + + if (distanceFromTop < AUTO_SCROLL_CONFIG.edgeZone) { + startAutoScroll(-1, calculateScrollSpeed(distanceFromTop)); + } else if (distanceFromBottom < AUTO_SCROLL_CONFIG.edgeZone) { + startAutoScroll(1, calculateScrollSpeed(distanceFromBottom)); + } else { + stopAutoScroll(); + } + }, + update: function(event, ui) { + const eventId = ui.item.attr('data-event-id'); + const originalPosition = ui.item.data('original-position'); + + // Count only the event containers before this one (not timeline header/rail) + const allEvents = $('.timeline-events-container .timeline-event-container:not(.timeline-event-template)'); + const newPosition = allEvents.index(ui.item); + + if (!eventId) { + console.error('Event ID not found'); + return; + } + + // Send the position directly - backend will convert to 1-based indexing + const targetPosition = newPosition; + + // Show loading state + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saving'; + } + + // AJAX request to update position using new internal endpoint + $.ajax({ + url: '/internal/sort/timeline_events', + type: 'PATCH', + contentType: 'application/json', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + data: JSON.stringify({ + content_id: eventId, + intended_position: targetPosition + }), + success: function(data) { + console.log('Timeline event position updated successfully:', data); + + // Update Alpine.js save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + setTimeout(() => { + if (Alpine.$data(alpineEl).autoSaveStatus === 'saved') { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + } + }, 2000); + } + + if (data.message) { + showTimelineSuccessMessage(data.message); + } + }, + error: function(xhr, status, error) { + console.error('Error updating timeline event position:', error); + + // Update Alpine.js save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'error'; + } + + // Revert to original position + const originalPosition = ui.item.data('original-position'); + if (typeof originalPosition !== 'undefined') { + revertEventPosition(ui.item, originalPosition); + } + + showTimelineErrorMessage('Failed to reorder events. Please try again.'); + } + }); + }, + stop: function(event, ui) { + // Remove visual feedback + ui.item.removeClass('timeline-event-dragging'); + // Stop any auto-scrolling that was active during drag + stopAutoScroll(); + } }); - $('.js-move-event-to-top').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); - - $.get( - "/plan/move/timeline_events/" + event_id + "/top" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); - var events_list = $('.timeline-events-container').find('.timeline-event-container'); - - event_container.insertBefore(events_list[0]); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); + // Add custom CSS for drag feedback and content drag & drop + if (!document.getElementById('timeline-drag-styles')) { + const style = document.createElement('style'); + style.id = 'timeline-drag-styles'; + style.textContent = ` + .timeline-event-placeholder { + height: 120px !important; + background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%), + linear-gradient(-45deg, #f3f4f6 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f3f4f6 75%), + linear-gradient(-45deg, transparent 75%, #f3f4f6 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + border: 2px dashed #10b981; + border-radius: 0.75rem; + margin: 0 0 2rem 0; + opacity: 0.7; + position: relative; + } + + .timeline-event-placeholder:before { + content: 'Drop event here'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #10b981; + font-weight: 500; + font-size: 0.875rem; + } + + .timeline-event-dragging { + transform: rotate(2deg); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + z-index: 1000; + } + + .ui-sortable-helper { + width: auto !important; + max-width: 600px; + } + + /* Content drag & drop styles */ + .draggable-content-item { + user-select: none; + } + + .draggable-content-item.dragging { + opacity: 0.5; + transform: scale(0.95); + } + + .event-drop-zone.drop-zone-active { + border-color: #10b981 !important; + border-width: 2px !important; + background-color: #f0fdf4 !important; + } + + .event-drop-zone.drop-zone-hover { + border-color: #059669 !important; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1) !important; + transform: scale(1.02); + } + + .drop-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 10; + background: rgba(16, 185, 129, 0.9); + color: white; + padding: 8px 16px; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + } + `; + document.head.appendChild(style); + } + + // Initialize content drag & drop functionality + initContentDragDrop(); +} + +// Initialize drag & drop for content linking +function initContentDragDrop() { + // Set up drag handlers for content items + document.addEventListener('dragstart', function(e) { + if (e.target.classList.contains('draggable-content-item')) { + const contentType = e.target.dataset.contentType; + const contentId = e.target.dataset.contentId; + const contentName = e.target.dataset.contentName; - return false; + // Store data for drop handler + e.dataTransfer.setData('application/json', JSON.stringify({ + contentType: contentType, + contentId: contentId, + contentName: contentName + })); + + e.dataTransfer.effectAllowed = 'copy'; + + // Add dragging visual state + e.target.classList.add('dragging'); + + // Show all event drop zones + document.querySelectorAll('.event-drop-zone').forEach(zone => { + zone.classList.add('drop-zone-active'); + }); + } }); - $('.js-move-event-up').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); + document.addEventListener('dragend', function(e) { + if (e.target.classList.contains('draggable-content-item')) { + // Remove dragging visual state + e.target.classList.remove('dragging'); - $.get( - "/plan/move/timeline_events/" + event_id + "/up" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); + // Hide all event drop zones + document.querySelectorAll('.event-drop-zone').forEach(zone => { + zone.classList.remove('drop-zone-active', 'drop-zone-hover'); + // Remove any drop indicators + const indicator = zone.querySelector('.drop-indicator'); + if (indicator) indicator.remove(); + }); + } + }); - event_container.insertBefore(event_container.prev()); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); + // Set up drop handlers for timeline events + document.addEventListener('dragover', function(e) { + if (e.target.closest('.event-drop-zone')) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + + const dropZone = e.target.closest('.event-drop-zone'); + dropZone.classList.add('drop-zone-hover'); - return false; + // Add drop indicator if not already present + if (!dropZone.querySelector('.drop-indicator')) { + const indicator = document.createElement('div'); + indicator.className = 'drop-indicator'; + indicator.textContent = 'Drop to link content'; + dropZone.style.position = 'relative'; + dropZone.appendChild(indicator); + } + } }); - $('.js-move-event-down').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); + document.addEventListener('dragleave', function(e) { + const dropZone = e.target.closest('.event-drop-zone'); + if (dropZone && !dropZone.contains(e.relatedTarget)) { + dropZone.classList.remove('drop-zone-hover'); + const indicator = dropZone.querySelector('.drop-indicator'); + if (indicator) indicator.remove(); + } + }); - $.get( - "/plan/move/timeline_events/" + event_id + "/down" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); + document.addEventListener('drop', function(e) { + const dropZone = e.target.closest('.event-drop-zone'); + if (dropZone) { + e.preventDefault(); - event_container.insertAfter(event_container.next()); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); + try { + const dragData = JSON.parse(e.dataTransfer.getData('application/json')); + const eventId = dropZone.dataset.eventId; + + if (eventId && dragData.contentType && dragData.contentId) { + linkContentToEvent(eventId, dragData.contentType, dragData.contentId, dragData.contentName, dropZone); + } + } catch (error) { + console.error('Error parsing drag data:', error); + } + + // Clean up visual states + dropZone.classList.remove('drop-zone-hover', 'drop-zone-active'); + const indicator = dropZone.querySelector('.drop-indicator'); + if (indicator) indicator.remove(); + } + }); +} + +// Replace linked content section with server-rendered HTML +function replaceLinkedContentSection(eventId, html) { + const eventContainer = document.querySelector(`[data-event-id="${eventId}"]`); + if (!eventContainer) return; + + // Find the current linked content section or the location where it should be inserted + const existingSection = eventContainer.querySelector(`#linked-content-${eventId}`); + const cardBody = eventContainer.querySelector('.px-6.py-4.space-y-4'); + + if (existingSection) { + // Replace existing section + existingSection.outerHTML = html; + } else if (cardBody && html.trim()) { + // Insert new section at the end of the card body + cardBody.insertAdjacentHTML('beforeend', html); + } + + // Add entrance animation to the new section + const newSection = eventContainer.querySelector(`#linked-content-${eventId}`); + if (newSection) { + newSection.style.opacity = '0'; + newSection.style.transform = 'translateY(-10px)'; + setTimeout(() => { + newSection.style.transition = 'all 0.3s ease-out'; + newSection.style.opacity = '1'; + newSection.style.transform = 'translateY(0)'; + }, 10); + } +} + +// Link content to timeline event via drag & drop +function linkContentToEvent(eventId, contentType, contentId, contentName, dropZone) { + // Show loading indicator + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'drop-indicator'; + loadingIndicator.innerHTML = '
Linking...'; + dropZone.style.position = 'relative'; + dropZone.appendChild(loadingIndicator); + + // Set Alpine.js auto-save status to saving + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saving'; + } + + fetch(`/plan/timeline_events/${eventId}/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + }, + body: JSON.stringify({ + entity_type: contentType, + entity_id: contentId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + // Replace the linked content section with server-rendered HTML + replaceLinkedContentSection(eventId, data.html); + + // Update sidebar linked content if this event is selected + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl && Alpine.$data(alpineEl).selectedEventId == eventId) { + Alpine.$data(alpineEl).updateSidebarLinkedContent(eventId); + } + + // Show success feedback + loadingIndicator.innerHTML = 'check_circleLinked!'; + loadingIndicator.className = 'drop-indicator'; + + // Update Alpine.js save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + } + + setTimeout(() => { + loadingIndicator.remove(); + }, 2000); + + showTimelineSuccessMessage(`${contentName} linked to event successfully!`); + } else { + throw new Error(data.message || 'Failed to link content'); + } + }) + .catch(error => { + console.error('Error linking content:', error); + + // Show error feedback + loadingIndicator.innerHTML = 'errorFailed'; + loadingIndicator.className = 'drop-indicator'; + loadingIndicator.style.background = 'rgba(239, 68, 68, 0.9)'; + + // Update Alpine.js save status + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'error'; + } + + setTimeout(() => { + loadingIndicator.remove(); + }, 3000); + + showTimelineErrorMessage('Failed to link content. Please try again.'); + }); +} + +// Helper function to revert event position on error +function revertEventPosition(eventItem, originalPosition) { + const eventsContainer = eventItem.parent(); + const allEvents = eventsContainer.children('.timeline-event-container:not(.timeline-event-template)'); + + if (originalPosition === 0) { + // Insert before the first event (after header/rail) + allEvents.first().before(eventItem); + } else if (originalPosition >= allEvents.length - 1) { + // Insert after the last event + allEvents.last().after(eventItem); + } else { + // Insert before the event at the target position + allEvents.eq(originalPosition).before(eventItem); + } +} + +// Timeline-specific notification functions +function showTimelineSuccessMessage(message) { + showNotificationToast(message, 'success'); +} + +function showTimelineErrorMessage(message) { + showNotificationToast(message, 'error'); +} + +function showNotificationToast(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 toast = $(` +
+ ${icon} + ${message} + +
+ `); + + $('body').append(toast); + + // Animate in + setTimeout(() => { + toast.removeClass('translate-x-full'); + }, 10); - return false; + // Auto-remove after 4 seconds + setTimeout(() => { + toast.addClass('translate-x-full'); + setTimeout(() => toast.remove(), 300); + }, 4000); +} + +document.addEventListener('DOMContentLoaded', function() { + // Only run on timeline editor page + if (!document.querySelector('.timeline-events-container')) return; + + // Initialize timeline events drag and drop + initTimelineEventsSortable(); + + // Auto-save functionality + document.addEventListener('ajax:success', function(event) { + + if (event.target.matches('.timeline-meta-form, .autosave-form')) { + // Update autoSaveStatus through Alpine data + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + setTimeout(() => { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + }, 2000); + } + } + }); + + document.addEventListener('ajax:error', function(event) { + + if (event.target.matches('.timeline-meta-form, .autosave-form')) { + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'error'; + } + } }); - $('.js-move-event-to-bottom').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); - - $.get( - "/plan/move/timeline_events/" + event_id + "/bottom" - ).done(function () { - // Move in the UI - var event_id = get_event_id_from_url(this.url); - var event_container = $('.timeline-events-container').find('.timeline-event-container[data-event-id="' + event_id + '"]'); - var events_list = $('.timeline-events-container').find('.timeline-event-container'); - - event_container.insertAfter(events_list[events_list.length - 1]); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); + // Create timeline event + document.getElementById('js-create-timeline-event').addEventListener('click', function() { + const timelineId = document.querySelector('.timeline-events-container').dataset.timelineId; + createTimelineEvent(timelineId); + }); + + // Create first timeline event + const firstEventBtn = document.getElementById('js-create-first-event'); + if (firstEventBtn) { + firstEventBtn.addEventListener('click', function() { + const timelineId = document.querySelector('.timeline-events-container').dataset.timelineId; + createTimelineEvent(timelineId); }); + } + + function createTimelineEvent(timelineId) { + + // Show loading state + const createBtn = document.getElementById('js-create-timeline-event'); + const originalText = createBtn.innerHTML; + createBtn.disabled = true; + createBtn.innerHTML = '
Creating...'; + + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) Alpine.$data(alpineEl).autoSaveStatus = 'saving'; + + fetch('/plan/timeline_events', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + }, + body: JSON.stringify({ + timeline_event: { + title: "Untitled Event", + timeline_id: timelineId, + event_type: "general", + status: "completed" + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success' && data.html) { + addEventToTimeline(data.id, data.html); + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + } else { + throw new Error('Failed to create event'); + } + }) + .catch(error => { + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) Alpine.$data(alpineEl).autoSaveStatus = 'error'; + showErrorMessage('Failed to create timeline event. Please try again.'); + }) + .finally(() => { + // Reset button state + createBtn.disabled = false; + createBtn.innerHTML = originalText; + }); + } + + function addEventToTimeline(eventId, html) { + const eventsContainer = document.querySelector('.timeline-events-container'); + + // Hide empty state if it exists + const emptyState = eventsContainer.querySelector('.text-center.py-16'); + if (emptyState) { + emptyState.style.display = 'none'; + } + + // Insert the server-rendered HTML + eventsContainer.insertAdjacentHTML('beforeend', html); + + // Get reference to the newly added event + const newEvent = eventsContainer.querySelector(`[data-event-id="${eventId}"]`); + if (!newEvent) { + showErrorMessage('Failed to add event to timeline. Please refresh the page.'); + return; + } + + // Add entrance animation + newEvent.style.opacity = '0'; + newEvent.style.transform = 'translateY(-20px)'; + + // Trigger animation + setTimeout(() => { + newEvent.style.transition = 'all 0.3s ease-out'; + newEvent.style.opacity = '1'; + newEvent.style.transform = 'translateY(0)'; + }, 10); + + // Update event count in header + const eventCount = eventsContainer.querySelectorAll('.timeline-event-container:not(.timeline-event-template)').length; + updateEventCount(eventCount); + + // Initialize drag and drop for the new event (if sortable is initialized) + if ($.fn.sortable && $(eventsContainer).hasClass('ui-sortable')) { + $(eventsContainer).sortable('refresh'); + } + + // Auto-select the newly created event in the Event Details panel + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + setTimeout(() => { + const alpineData = Alpine.$data(alpineEl); + if (alpineData && typeof alpineData.selectEvent === 'function') { + // Extract event data from the newly added element + const title = newEvent.querySelector('input[name*="[title]"]'); + const timeLabel = newEvent.querySelector('input[name*="[time_label]"]'); + const endTimeLabel = newEvent.querySelector('input[name*="[end_time_label]"]'); + const description = newEvent.querySelector('textarea[name*="[description]"]'); + + const eventData = { + id: eventId, + title: title ? title.value : 'Untitled Event', + time_label: timeLabel ? timeLabel.value : '', + end_time_label: endTimeLabel ? endTimeLabel.value : '', + description: description ? description.value : '', + event_type: newEvent.dataset.eventType || 'general', + status: newEvent.dataset.status || 'completed', + tags: [] + }; + + alpineData.selectEvent(eventId, eventData); + } + }, 100); + } + + // Focus on the title field for immediate editing + setTimeout(() => { + const titleField = newEvent.querySelector('input[name*="[title]"]'); + if (titleField) { + titleField.focus(); + titleField.select(); + } + }, 350); + + // Scroll the new event into view + setTimeout(() => { + newEvent.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + } + + function updateEventCount(count) { + const eventCountElement = document.querySelector('.hidden.sm\\:flex .text-sm.text-gray-600'); + if (eventCountElement) { + const text = count === 1 ? '1 event' : `${count} events`; + eventCountElement.firstChild.textContent = text; + } + } + + function showErrorMessage(message) { + // Create and show a toast notification + const toast = document.createElement('div'); + toast.className = 'fixed top-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform translate-x-full transition-transform'; + toast.innerHTML = ` +
+ error + ${message} + +
+ `; + + document.body.appendChild(toast); + + // Animate in + setTimeout(() => { + toast.style.transform = 'translateX(0)'; + }, 10); + + // Auto remove after 5 seconds + setTimeout(() => { + if (toast.parentElement) { + toast.style.transform = 'translateX(full)'; + setTimeout(() => toast.remove(), 300); + } + }, 5000); + } + + // Link entity functionality + document.addEventListener('click', function(event) { + if (event.target.matches('.js-link-entity-selection') || event.target.closest('.js-link-entity-selection')) { + const button = event.target.matches('.js-link-entity-selection') ? event.target : event.target.closest('.js-link-entity-selection'); + const entityType = button.dataset.type; + const entityId = button.dataset.id; + const alpineEl = document.querySelector('[x-data*="timelineEditor"]'); + const eventId = alpineEl && window.Alpine ? Alpine.$data(alpineEl).linkingEventId : null; + + if (eventId) { + // Show loading state on the clicked button + const originalContent = button.innerHTML; + button.innerHTML = '
Linking...
'; + button.disabled = true; - return false; + fetch(`/plan/timeline_events/${eventId}/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + }, + body: JSON.stringify({ + entity_type: entityType, + entity_id: entityId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + // Replace the linked content section with server-rendered HTML + replaceLinkedContentSection(eventId, data.html); + + // Update sidebar linked content if this event is selected + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl && Alpine.$data(alpineEl).selectedEventId == eventId) { + Alpine.$data(alpineEl).updateSidebarLinkedContent(eventId); + } + + // Show success feedback on the button itself + const originalContent = button.innerHTML; + button.innerHTML = '
check_circleAdded!
'; + button.classList.add('bg-green-50', 'border-green-300', 'text-green-800'); + + // Reset button after 2 seconds but show linked state with name + setTimeout(() => { + const entityName = button.dataset.name || 'Content'; + button.innerHTML = `
check_circle${entityName}
Linked
`; + button.classList.remove('bg-green-50', 'border-green-300', 'text-green-800'); + button.classList.add('bg-gray-50', 'border-gray-300', 'text-gray-500', 'cursor-not-allowed'); + button.disabled = true; + }, 2000); + } else { + throw new Error(data.message || 'Failed to link content'); + } + }) + .catch(error => { + showErrorMessage('Error linking content. Please try again.'); + }) + .finally(() => { + // Reset button state + button.innerHTML = originalContent; + button.disabled = false; + }); + } + } }); - $('#js-create-timeline-event').click(function () { - var events_container = $('.timeline-events-container'); - var loading_indicator = $('.loading-indicator'); - - // Indiate we're LOADING! - loading_indicator.show(); - $('#js-create-timeline-event').attr('disabled', 'disabled'); - - // TODO hit the endpoint to create an event - $.post( - "/plan/timeline_events", - { - "timeline_event": { - "title": "Untitled Event", - "timeline_id": events_container.data('timeline-id') + + // Update unlink functionality to use Rails UJS instead of manual fetch + // The unlink buttons now have remote: true, so they'll be handled by Rails UJS + document.addEventListener('ajax:success', function(event) { + if (event.target.matches('a[href*="/unlink/"]')) { + const response = event.detail[0]; + if (response.status === 'success') { + // Extract event ID from the URL + const eventId = event.target.href.match(/\/timeline_events\/(\d+)\/unlink/)[1]; + replaceLinkedContentSection(eventId, response.html); + + // Update sidebar linked content if this event is selected + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl && Alpine.$data(alpineEl).selectedEventId == eventId) { + Alpine.$data(alpineEl).updateSidebarLinkedContent(eventId); } + + showSuccessMessage(response.message || 'Content unlinked successfully!'); } - ).done(function (data) { - var new_event_id = data["id"]; - var template = $('.timeline-event-template > .timeline-event-container'); - var cloned_template = template.clone(true).removeClass('timeline-event-template'); - var timeline_id = cloned_template.find('.timeline-event-container').first().data('timeline-id'); - // console.log('new event id = ' + new_event_id); - // console.log('timeline_id = ' + timeline_id); + } + }); + + function showSuccessMessage(message) { + const toast = document.createElement('div'); + toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform translate-x-full transition-transform'; + toast.innerHTML = ` +
+ check_circle + ${message} + +
+ `; - // Update IDs to the newly-created event - cloned_template.data('event-id', new_event_id); - cloned_template.attr('data-event-id', new_event_id); + document.body.appendChild(toast); - // Update labels to jump to this event's fields - var title_field = cloned_template.find('.ref-title'); - title_field.find('input').attr('id', 'timeline-event-title-' + new_event_id); - title_field.find('label').attr('for', 'timeline-event-title-' + new_event_id); + // Animate in + setTimeout(() => { + toast.style.transform = 'translateX(0)'; + }, 10); - var desc_field = cloned_template.find('.ref-description'); - desc_field.find('textarea').attr('id', 'timeline-event-description-' + new_event_id); - desc_field.find('label').attr('for', 'timeline-event-description-' + new_event_id); + // Auto remove after 3 seconds + setTimeout(() => { + if (toast.parentElement) { + toast.style.transform = 'translateX(full)'; + setTimeout(() => toast.remove(), 300); + } + }, 3000); + } - var notes_field = cloned_template.find('.ref-notes'); - notes_field.find('textarea').attr('id', 'timeline-event-notes-' + new_event_id); - notes_field.find('label').attr('for', 'timeline-event-notes-' + new_event_id); + // Move event handlers + document.addEventListener('click', function(event) { + const eventContainer = event.target.closest('.timeline-event-container'); + const eventId = eventContainer?.dataset.eventId; - //cloned_template.find('input[name="timeline_event[timeline_id]"]').val(timeline_id); - cloned_template.find('.js-delete-timeline-event').attr('href', '/plan/timeline_events/' + new_event_id); - cloned_template.find('.autosave-form').attr('action', '/plan/timeline_events/' + new_event_id); + if (!eventId || eventId === '-1') return; - cloned_template.appendTo(events_container); + let endpoint = null; + if (event.target.closest('.js-move-event-to-top')) { + endpoint = `/plan/timeline_events/${eventId}/move/top`; + } else if (event.target.closest('.js-move-event-up')) { + endpoint = `/plan/timeline_events/${eventId}/move/up`; + } else if (event.target.closest('.js-move-event-down')) { + endpoint = `/plan/timeline_events/${eventId}/move/down`; + } else if (event.target.closest('.js-move-event-to-bottom')) { + endpoint = `/plan/timeline_events/${eventId}/move/bottom`; + } - loading_indicator.hide(); - $('#js-create-timeline-event').removeAttr('disabled'); + if (endpoint) { + event.preventDefault(); + fetch(endpoint, { + method: 'GET', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + } + }) + .then(() => { + moveEventInDOM(eventContainer, endpoint); + showSuccessMessage('Event moved successfully!'); + }) + .catch(error => { + showErrorMessage('Error moving event. Please try again.'); + }); + } + }); + + function moveEventInDOM(eventContainer, endpoint) { + const eventsContainer = eventContainer.parentElement; + const allEvents = Array.from(eventsContainer.children).filter(el => + el.classList.contains('timeline-event-container') && + !el.classList.contains('timeline-event-template') + ); + + const currentIndex = allEvents.indexOf(eventContainer); + let newIndex; + + // Determine new position based on action + if (endpoint.includes('/top')) { + newIndex = 0; + } else if (endpoint.includes('/bottom')) { + newIndex = allEvents.length - 1; + } else if (endpoint.includes('/up')) { + newIndex = Math.max(0, currentIndex - 1); + } else if (endpoint.includes('/down')) { + newIndex = Math.min(allEvents.length - 1, currentIndex + 1); + } + + // Only move if position actually changes + if (newIndex !== currentIndex) { + // Add animation class + eventContainer.style.transition = 'all 0.3s ease-out'; + eventContainer.style.transform = 'scale(1.02)'; + eventContainer.style.boxShadow = '0 10px 25px rgba(0,0,0,0.1)'; + + setTimeout(() => { + // Move in DOM + if (newIndex === 0) { + eventsContainer.insertBefore(eventContainer, allEvents[0]); + } else if (newIndex === allEvents.length - 1) { + eventsContainer.appendChild(eventContainer); + } else { + const referenceEvent = allEvents[newIndex]; + if (currentIndex < newIndex) { + eventsContainer.insertBefore(eventContainer, referenceEvent.nextSibling); + } else { + eventsContainer.insertBefore(eventContainer, referenceEvent); + } + } + + // Reset animation + setTimeout(() => { + eventContainer.style.transform = 'scale(1)'; + eventContainer.style.boxShadow = ''; + + setTimeout(() => { + eventContainer.style.transition = ''; + }, 300); + }, 50); + + // Scroll into view + eventContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 150); + } + } - }).fail(function () { - alert('Error 292'); + // Global function for unlinking from sidebar + window.unlinkFromSidebar = function(unlinkHref, button) { - loading_indicator.hide(); - $('#js-create-timeline-event').removeAttr('disabled'); + // Show loading state + const originalHTML = button.innerHTML; + button.innerHTML = '
'; + button.disabled = true; + + // Make request to unlink + fetch(unlinkHref, { + method: 'POST', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + // Update main linked content section + const eventId = unlinkHref.match(/timeline_events\/(\d+)\/unlink/)[1]; + replaceLinkedContentSection(eventId, data.html); + showSuccessMessage(data.message || 'Content unlinked successfully!'); + } else { + throw new Error(data.message || 'Failed to unlink content'); + } + }) + .catch(error => { + button.innerHTML = originalHTML; + button.disabled = false; + showErrorMessage('Error unlinking content. Please try again.'); }); + }; - // return false so we don't jump to the top of the page - return false; - }); + // Make deleteEvent globally available + window.deleteEvent = function(eventId, button) { + const eventContainer = button.closest('.timeline-event-container'); + + // If this is a template event (not yet saved), just remove it + if (!eventId || eventId === 'null') { + eventContainer.remove(); + return; + } + + // Show confirmation modal + if (!confirm('Are you sure you want to delete this event? This cannot be undone.')) { + return; + } + + // Show loading state + const originalIcon = button.innerHTML; + button.innerHTML = '
'; + button.disabled = true; + + fetch(`/plan/timeline_events/${eventId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + } + }) + .then(() => { + // Clear inspector selection if this event was selected + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl && Alpine.$data(alpineEl).selectedEventId == eventId) { + Alpine.$data(alpineEl).clearEventSelection(); + } + + // Animate removal + eventContainer.style.transition = 'all 0.3s ease-in'; + eventContainer.style.opacity = '0'; + eventContainer.style.transform = 'translateX(-20px)'; + + setTimeout(() => { + eventContainer.remove(); + + // Update event count + const eventsContainer = document.querySelector('.timeline-events-container'); + const eventCount = eventsContainer.querySelectorAll('.timeline-event-container:not(.timeline-event-template)').length; + updateEventCount(eventCount); + + // Show empty state if no events remain + if (eventCount === 0) { + showEmptyState(); + } + + showSuccessMessage('Event deleted successfully!'); + }, 300); + }) + .catch(error => { + button.innerHTML = originalIcon; + button.disabled = false; + showErrorMessage('Error deleting event. Please try again.'); + }); + }; + + function showEmptyState() { + const eventsContainer = document.querySelector('.timeline-events-container'); + const emptyState = document.createElement('div'); + emptyState.className = 'text-center py-16'; + emptyState.innerHTML = ` +
+ timeline +
+

Your timeline is empty

+

+ 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 = ` +
+ search_off +
+

No events found

+

+ 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 = ` +
+ info + Linked content will appear here when you connect pages to this event +
+ `; + + const mainLinkedContent = document.querySelector(`#linked-content-${eventId}`); + if (!mainLinkedContent) { + containers.forEach(container => { + container.innerHTML = emptyStateHTML; + }); + return; + } + + const linkedCards = mainLinkedContent.querySelectorAll('.linked-content-card'); + + if (linkedCards.length === 0) { + containers.forEach(container => { + container.innerHTML = emptyStateHTML; + }); + return; + } + + let sidebarHTML = ''; + linkedCards.forEach(card => { + const nameLink = card.querySelector('.type-badge a'); + const icon = card.querySelector('.type-badge i'); + const removeButton = card.querySelector('.remove-btn'); + + if (nameLink && icon) { + const name = nameLink.textContent.trim(); + const href = nameLink.getAttribute('href'); + const iconClass = icon.className; + const iconText = icon.textContent; + const removeHref = removeButton ? removeButton.getAttribute('href') : ''; + + sidebarHTML += ` +
+
+ ${iconText} + + ${name} + +
+ ${removeButton ? ` + + ` : ''} +
+ `; + } + }); + + // Update all containers with the same content + containers.forEach(container => { + container.innerHTML = sidebarHTML; + }); + }, + + // Computed properties for live form data + get selectedEventTitle() { + if (!this.selectedEventId) return ''; + if (this.liveTitle) return this.liveTitle; + const input = document.querySelector(`[data-event-id="${this.selectedEventId}"] input[name*="[title]"]`); + return input?.value || 'Untitled Event'; + }, + + get selectedEventTimeLabel() { + if (!this.selectedEventId) return ''; + if (this.liveTimeLabel !== '') return this.liveTimeLabel; + const input = document.querySelector(`[data-event-id="${this.selectedEventId}"] input[name*="[time_label]"]`); + return input?.value || ''; + }, + + get selectedEventEndTimeLabel() { + if (!this.selectedEventId) return ''; + if (this.liveEndTimeLabel !== '') return this.liveEndTimeLabel; + const input = document.querySelector(`[data-event-id="${this.selectedEventId}"] input[name*="[end_time_label]"]`); + return input?.value || ''; + }, + + get selectedEventDescription() { + if (!this.selectedEventId) return ''; + if (this.liveDescription !== '') return this.liveDescription; + const textarea = document.querySelector(`[data-event-id="${this.selectedEventId}"] textarea[name*="[description]"]`); + return textarea?.value || ''; + }, + + get selectedEventType() { + if (!this.selectedEventId) return 'general'; + return this.selectedEventData?.event_type || 'general'; + }, + + // Event type helper methods - now using consolidated EVENT_TYPES + getEventTypeIcon(eventId) { + if (!eventId) return 'radio_button_checked'; + const eventType = this.selectedEventData?.event_type || 'general'; + return EVENT_TYPES[eventType]?.icon || 'radio_button_checked'; + }, + + getEventTypeName(eventId) { + if (!eventId) return 'General'; + const eventType = this.selectedEventData?.event_type || 'general'; + return EVENT_TYPES[eventType]?.name || 'General'; + }, + + // Update event type and refresh timeline dot + updateEventType(newType) { + if (!this.selectedEventId) return; + + if (this.selectedEventData) { + this.selectedEventData.event_type = newType; + } + + const eventContainer = document.querySelector(`[data-event-id="${this.selectedEventId}"]`); + if (eventContainer) { + const form = document.createElement('form'); + form.setAttribute('method', 'POST'); + form.setAttribute('action', `/plan/timeline_events/${this.selectedEventId}`); + form.classList.add('autosave-form'); + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + if (csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'authenticity_token'; + csrfInput.value = csrfToken; + form.appendChild(csrfInput); + } + + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = 'patch'; + form.appendChild(methodInput); + + const eventTypeInput = document.createElement('input'); + eventTypeInput.type = 'hidden'; + eventTypeInput.name = 'timeline_event[event_type]'; + eventTypeInput.value = newType; + form.appendChild(eventTypeInput); + + submitFormWithFetch(form); + + // Update the timeline dot icon using consolidated EVENT_TYPES + const timelineDotContainer = eventContainer.querySelector('.timeline-dot'); + const timelineDot = timelineDotContainer?.querySelector('i'); + if (timelineDot && timelineDotContainer) { + timelineDot.textContent = EVENT_TYPES[newType]?.icon || 'radio_button_checked'; + timelineDotContainer.title = EVENT_TYPES[newType]?.name || 'General'; + } + } + }, + + // Tag management methods + getEventTags(eventId) { + if (!eventId) return []; + + if (this.eventTags[eventId]) { + return this.eventTags[eventId]; + } + + const tagContainer = document.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; + return tags; + }, + + loadEventTags(eventId) { + if (!eventId) return; + + const eventContainer = document.querySelector(`[data-event-id="${eventId}"]`); + if (!eventContainer) { + this.eventTags[eventId] = []; + return; + } + + this.eventTags[eventId] = []; + }, + + addEventTag(tagName) { + if (!this.selectedEventId || !tagName.trim()) return; + + const cleanTagName = tagName.trim(); + const existingTags = this.getEventTags(this.selectedEventId); + if (existingTags.includes(cleanTagName)) return; + + if (!this.eventTags[this.selectedEventId]) { + this.eventTags[this.selectedEventId] = []; + } + this.eventTags[this.selectedEventId].push(cleanTagName); + + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + this.updateMainPanelTags(this.selectedEventId); + this.loadingTags.add(`${this.selectedEventId}-${cleanTagName}`); + this.autoSaveStatus = 'saving'; + + fetch(`/plan/timeline_events/${this.selectedEventId}/tags`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'), + 'Accept': 'application/json' + }, + body: JSON.stringify({ tag_name: cleanTagName }) + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + this.loadingTags.delete(`${this.selectedEventId}-${cleanTagName}`); + this.autoSaveStatus = 'saved'; + setTimeout(() => { + if (this.autoSaveStatus === 'saved') { + this.autoSaveStatus = 'saved'; + } + }, 1000); + } else { + throw new Error(data.message || 'Failed to add tag'); + } + }) + .catch(error => { + console.error('Error adding tag:', error); + this.autoSaveStatus = 'error'; + this.loadingTags.delete(`${this.selectedEventId}-${cleanTagName}`); + + if (this.eventTags[this.selectedEventId]) { + this.eventTags[this.selectedEventId] = this.eventTags[this.selectedEventId].filter(tag => tag !== cleanTagName); + } + + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + this.updateMainPanelTags(this.selectedEventId); + + setTimeout(() => { + if (this.autoSaveStatus === 'error') { + this.autoSaveStatus = 'saved'; + } + }, 3000); + }); + }, + + removeEventTag(tagName) { + if (!this.selectedEventId || !tagName) return; + + const originalTags = [...(this.eventTags[this.selectedEventId] || [])]; + this.loadingTags.add(`${this.selectedEventId}-${tagName}`); + + if (this.eventTags[this.selectedEventId]) { + this.eventTags[this.selectedEventId] = this.eventTags[this.selectedEventId].filter(tag => tag !== tagName); + } + + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + this.updateMainPanelTags(this.selectedEventId); + this.autoSaveStatus = 'saving'; + + fetch(`/plan/timeline_events/${this.selectedEventId}/tags/${encodeURIComponent(tagName)}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'), + 'Accept': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success') { + this.loadingTags.delete(`${this.selectedEventId}-${tagName}`); + this.autoSaveStatus = 'saved'; + setTimeout(() => { + if (this.autoSaveStatus === 'saved') { + this.autoSaveStatus = 'saved'; + } + }, 1000); + } else { + throw new Error(data.message || 'Failed to remove tag'); + } + }) + .catch(error => { + console.error('Error removing tag:', error); + this.autoSaveStatus = 'error'; + this.loadingTags.delete(`${this.selectedEventId}-${tagName}`); + this.eventTags[this.selectedEventId] = originalTags; + + if (this._tagContainerCache) { + this._tagContainerCache.delete(this.selectedEventId); + } + + this.updateMainPanelTags(this.selectedEventId); + + setTimeout(() => { + if (this.autoSaveStatus === 'error') { + this.autoSaveStatus = 'saved'; + } + }, 3000); + }); + }, + + isTagLoading(eventId, tagName) { + return this.loadingTags.has(`${eventId}-${tagName}`); + }, + + updateMainPanelTags(eventId) { + if (!this._tagContainerCache) { + this._tagContainerCache = new Map(); + } + + let tagContainer = this._tagContainerCache.get(eventId); + if (!tagContainer) { + tagContainer = document.querySelector(`#event-tags-${eventId}`); + if (!tagContainer) return; + this._tagContainerCache.set(eventId, tagContainer); + } + + const eventTags = this.getEventTags(eventId); + + if (eventTags.length === 0) { + if (!tagContainer.classList.contains('hidden')) { + tagContainer.classList.add('hidden'); + tagContainer.innerHTML = ''; + } + } else { + const needsHighlightUpdate = this.tagFilterMode === 'highlight' && this.selectedTagFilters.length > 0; + const currentContent = tagContainer.innerHTML; + + const newContent = eventTags.map(tag => { + const isHighlighted = needsHighlightUpdate && this.selectedTagFilters.includes(tag); + return ` + + ${tag} + + `; + }).join(''); + + if (currentContent !== newContent) { + tagContainer.classList.remove('hidden'); + tagContainer.innerHTML = newContent; + } + } + }, + + clearTagFilters() { + this.selectedTagFilters = []; + this.applyEventFilters(); + + if (this.tagFilterMode === 'highlight') { + Object.keys(this.eventTags).forEach(eventId => { + this.updateMainPanelTags(eventId); + }); + } + }, + + changeTagFilterMode(newMode) { + this.tagFilterMode = newMode; + this.applyEventFilters(); + }, + + // Inline edit methods + saveTitle() { + this.timelineName = this.tempTitle; + const form = document.querySelector('[x-ref="titleForm"]'); + if (form) { + const input = form.querySelector('input[name="timeline[name]"]'); + if (input) { + input.value = this.tempTitle; + } + this.editingTitle = false; + submitFormWithFetch(form); + } + }, + + saveSubtitle() { + this.timelineSubtitle = this.tempSubtitle; + const form = document.querySelector('[x-ref="subtitleForm"]'); + if (form) { + const input = form.querySelector('input[name="timeline[subtitle]"]'); + if (input) { + input.value = this.tempSubtitle; + } + this.editingSubtitle = false; + submitFormWithFetch(form); + } + } + }; +} diff --git a/app/assets/javascripts/timeline_form_helpers.js b/app/assets/javascripts/timeline_form_helpers.js new file mode 100644 index 000000000..844b3734f --- /dev/null +++ b/app/assets/javascripts/timeline_form_helpers.js @@ -0,0 +1,156 @@ +// Timeline Form Helpers +// Extracted from timelines/edit.html.erb for reusability + +// Global function for updating timeline tags +function updateTimelineTags(timelineId, tags) { + const tagString = tags.join(',,,|||,,,'); + console.log('Sending tags:', tagString, 'from array:', tags); + + // Create a temporary form to submit the tag update + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `/plan/timelines/${timelineId}`; + form.style.display = 'none'; + + // Add CSRF token + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'authenticity_token'; + csrfInput.value = document.querySelector('meta[name=csrf-token]').content; + form.appendChild(csrfInput); + + // Add method override for PATCH + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = 'patch'; + form.appendChild(methodInput); + + // Add tags field - send all current tags + const tagsInput = document.createElement('input'); + tagsInput.type = 'hidden'; + tagsInput.name = 'timeline[page_tags]'; + tagsInput.value = tagString; + form.appendChild(tagsInput); + + // Submit form + document.body.appendChild(form); + return fetch(form.action, { + method: 'PATCH', + body: new FormData(form), + headers: { + 'X-CSRF-Token': csrfInput.value, + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Tags update successful:', data); + // Set status to indicate successful save + const alpineEl = document.querySelector('[x-data*="timelineEditor"]'); + if (alpineEl && window.Alpine) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + setTimeout(() => { + if (Alpine.$data(alpineEl).autoSaveStatus === 'saved') { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + } + }, 2000); + } + }) + .catch(error => { + console.error('Error updating tags:', error); + // Revert to server state on error by reloading + location.reload(); + }) + .finally(() => { + document.body.removeChild(form); + }); +} + +// Helper function to submit forms remotely +function submitFormRemotely(form) { + if (form.getAttribute('data-remote') !== 'true') { + form.setAttribute('data-remote', 'true'); + } + + // Use Rails UJS to submit the form remotely + if (typeof Rails !== 'undefined' && Rails.fire) { + Rails.fire(form, 'submit'); + } else { + // Manual AJAX implementation since Rails UJS is not working + submitFormWithFetch(form); + } +} + +// Manual AJAX form submission function +function submitFormWithFetch(form) { + // Set auto-save status to saving + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saving'; + } + + const formData = new FormData(form); + const url = form.action; + const method = form.method.toUpperCase(); + + // Add Rails authenticity token if not present + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + if (csrfToken && !formData.has('authenticity_token')) { + formData.append('authenticity_token', csrfToken); + } + + fetch(url, { + method: method, + body: formData, + headers: { + 'X-CSRF-Token': csrfToken, + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + }) + .then(data => { + // Trigger success event manually + const successEvent = new CustomEvent('ajax:success', { + detail: [data], + bubbles: true, + cancelable: true + }); + form.dispatchEvent(successEvent); + + // Update auto-save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + setTimeout(() => { + Alpine.$data(alpineEl).autoSaveStatus = 'saved'; + }, 2000); + } + }) + .catch(error => { + // Trigger error event manually + const errorEvent = new CustomEvent('ajax:error', { + detail: [null, { status: 'error', message: error.message }], + bubbles: true, + cancelable: true + }); + form.dispatchEvent(errorEvent); + + // Update auto-save status + if (alpineEl) { + Alpine.$data(alpineEl).autoSaveStatus = 'error'; + } + }); +} diff --git a/app/assets/javascripts/vendor/materialize.min.js b/app/assets/javascripts/vendor/materialize.min.js deleted file mode 100644 index 7d80c9375..000000000 --- a/app/assets/javascripts/vendor/materialize.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Materialize v1.0.0 (http://materializecss.com) - * Copyright 2014-2017 Materialize - * MIT License (https://raw.githubusercontent.com/Dogfalo/materialize/master/LICENSE) - */ -var _get=function t(e,i,n){null===e&&(e=Function.prototype);var s=Object.getOwnPropertyDescriptor(e,i);if(void 0===s){var o=Object.getPrototypeOf(e);return null===o?void 0:t(o,i,n)}if("value"in s)return s.value;var a=s.get;return void 0!==a?a.call(n):void 0},_createClass=function(){function n(t,e){for(var i=0;i/,p=/^\w+$/;function v(t,e){e=e||o;var i=u.test(t)?e.getElementsByClassName(t.slice(1)):p.test(t)?e.getElementsByTagName(t):e.querySelectorAll(t);return i}function f(t){if(!i){var e=(i=o.implementation.createHTMLDocument(null)).createElement("base");e.href=o.location.href,i.head.appendChild(e)}return i.body.innerHTML=t,i.body.childNodes}function m(t){"loading"!==o.readyState?t():o.addEventListener("DOMContentLoaded",t)}function g(t,e){if(!t)return this;if(t.cash&&t!==a)return t;var i,n=t,s=0;if(d(t))n=l.test(t)?o.getElementById(t.slice(1)):c.test(t)?f(t):v(t,e);else if(h(t))return m(t),this;if(!n)return this;if(n.nodeType||n===a)this[0]=n,this.length=1;else for(i=this.length=n.length;ss.right-i||l+e.width>window.innerWidth-i)&&(n.right=!0),(ho-i||h+e.height>window.innerHeight-i)&&(n.bottom=!0),n},M.checkPossibleAlignments=function(t,e,i,n){var s={top:!0,right:!0,bottom:!0,left:!0,spaceOnTop:null,spaceOnRight:null,spaceOnBottom:null,spaceOnLeft:null},o="visible"===getComputedStyle(e).overflow,a=e.getBoundingClientRect(),r=Math.min(a.height,window.innerHeight),l=Math.min(a.width,window.innerWidth),h=t.getBoundingClientRect(),d=e.scrollLeft,u=e.scrollTop,c=i.left-d,p=i.top-u,v=i.top+h.height-u;return s.spaceOnRight=o?window.innerWidth-(h.left+i.width):l-(c+i.width),s.spaceOnRight<0&&(s.left=!1),s.spaceOnLeft=o?h.right-i.width:c-i.width+h.width,s.spaceOnLeft<0&&(s.right=!1),s.spaceOnBottom=o?window.innerHeight-(h.top+i.height+n):r-(p+i.height+n),s.spaceOnBottom<0&&(s.top=!1),s.spaceOnTop=o?h.bottom-(i.height+n):v-(i.height-n),s.spaceOnTop<0&&(s.bottom=!1),s},M.getOverflowParent=function(t){return null==t?null:t===document.body||"visible"!==getComputedStyle(t).overflow?t:M.getOverflowParent(t.parentElement)},M.getIdFromTrigger=function(t){var e=t.getAttribute("data-target");return e||(e=(e=t.getAttribute("href"))?e.slice(1):""),e},M.getDocumentScrollTop=function(){return window.pageYOffset||document.documentElement.scrollTop||document.body.scrollTop||0},M.getDocumentScrollLeft=function(){return window.pageXOffset||document.documentElement.scrollLeft||document.body.scrollLeft||0};var getTime=Date.now||function(){return(new Date).getTime()};M.throttle=function(i,n,s){var o=void 0,a=void 0,r=void 0,l=null,h=0;s||(s={});var d=function(){h=!1===s.leading?0:getTime(),l=null,r=i.apply(o,a),o=a=null};return function(){var t=getTime();h||!1!==s.leading||(h=t);var e=n-(t-h);return o=this,a=arguments,e<=0?(clearTimeout(l),l=null,h=t,r=i.apply(o,a),o=a=null):l||!1===s.trailing||(l=setTimeout(d,e)),r}};var $jscomp={scope:{}};$jscomp.defineProperty="function"==typeof Object.defineProperties?Object.defineProperty:function(t,e,i){if(i.get||i.set)throw new TypeError("ES3 does not support getters and setters.");t!=Array.prototype&&t!=Object.prototype&&(t[e]=i.value)},$jscomp.getGlobal=function(t){return"undefined"!=typeof window&&window===t?t:"undefined"!=typeof global&&null!=global?global:t},$jscomp.global=$jscomp.getGlobal(this),$jscomp.SYMBOL_PREFIX="jscomp_symbol_",$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){},$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)},$jscomp.symbolCounter_=0,$jscomp.Symbol=function(t){return $jscomp.SYMBOL_PREFIX+(t||"")+$jscomp.symbolCounter_++},$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var t=$jscomp.global.Symbol.iterator;t||(t=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol("iterator")),"function"!=typeof Array.prototype[t]&&$jscomp.defineProperty(Array.prototype,t,{configurable:!0,writable:!0,value:function(){return $jscomp.arrayIterator(this)}}),$jscomp.initSymbolIterator=function(){}},$jscomp.arrayIterator=function(t){var e=0;return $jscomp.iteratorPrototype(function(){return e=k.currentTime)for(var h=0;ht&&(s.duration=e.duration),s.children.push(e)}),s.seek(0),s.reset(),s.autoplay&&s.restart(),s},s},O.random=function(t,e){return Math.floor(Math.random()*(e-t+1))+t},O}(),function(r,l){"use strict";var e={accordion:!0,onOpenStart:void 0,onOpenEnd:void 0,onCloseStart:void 0,onCloseEnd:void 0,inDuration:300,outDuration:300},t=function(t){function s(t,e){_classCallCheck(this,s);var i=_possibleConstructorReturn(this,(s.__proto__||Object.getPrototypeOf(s)).call(this,s,t,e));(i.el.M_Collapsible=i).options=r.extend({},s.defaults,e),i.$headers=i.$el.children("li").children(".collapsible-header"),i.$headers.attr("tabindex",0),i._setupEventHandlers();var n=i.$el.children("li.active").children(".collapsible-body");return i.options.accordion?n.first().css("display","block"):n.css("display","block"),i}return _inherits(s,Component),_createClass(s,[{key:"destroy",value:function(){this._removeEventHandlers(),this.el.M_Collapsible=void 0}},{key:"_setupEventHandlers",value:function(){var e=this;this._handleCollapsibleClickBound=this._handleCollapsibleClick.bind(this),this._handleCollapsibleKeydownBound=this._handleCollapsibleKeydown.bind(this),this.el.addEventListener("click",this._handleCollapsibleClickBound),this.$headers.each(function(t){t.addEventListener("keydown",e._handleCollapsibleKeydownBound)})}},{key:"_removeEventHandlers",value:function(){var e=this;this.el.removeEventListener("click",this._handleCollapsibleClickBound),this.$headers.each(function(t){t.removeEventListener("keydown",e._handleCollapsibleKeydownBound)})}},{key:"_handleCollapsibleClick",value:function(t){var e=r(t.target).closest(".collapsible-header");if(t.target&&e.length){var i=e.closest(".collapsible");if(i[0]===this.el){var n=e.closest("li"),s=i.children("li"),o=n[0].classList.contains("active"),a=s.index(n);o?this.close(a):this.open(a)}}}},{key:"_handleCollapsibleKeydown",value:function(t){13===t.keyCode&&this._handleCollapsibleClickBound(t)}},{key:"_animateIn",value:function(t){var e=this,i=this.$el.children("li").eq(t);if(i.length){var n=i.children(".collapsible-body");l.remove(n[0]),n.css({display:"block",overflow:"hidden",height:0,paddingTop:"",paddingBottom:""});var s=n.css("padding-top"),o=n.css("padding-bottom"),a=n[0].scrollHeight;n.css({paddingTop:0,paddingBottom:0}),l({targets:n[0],height:a,paddingTop:s,paddingBottom:o,duration:this.options.inDuration,easing:"easeInOutCubic",complete:function(t){n.css({overflow:"",paddingTop:"",paddingBottom:"",height:""}),"function"==typeof e.options.onOpenEnd&&e.options.onOpenEnd.call(e,i[0])}})}}},{key:"_animateOut",value:function(t){var e=this,i=this.$el.children("li").eq(t);if(i.length){var n=i.children(".collapsible-body");l.remove(n[0]),n.css("overflow","hidden"),l({targets:n[0],height:0,paddingTop:0,paddingBottom:0,duration:this.options.outDuration,easing:"easeInOutCubic",complete:function(){n.css({height:"",overflow:"",padding:"",display:""}),"function"==typeof e.options.onCloseEnd&&e.options.onCloseEnd.call(e,i[0])}})}}},{key:"open",value:function(t){var i=this,e=this.$el.children("li").eq(t);if(e.length&&!e[0].classList.contains("active")){if("function"==typeof this.options.onOpenStart&&this.options.onOpenStart.call(this,e[0]),this.options.accordion){var n=this.$el.children("li");this.$el.children("li.active").each(function(t){var e=n.index(r(t));i.close(e)})}e[0].classList.add("active"),this._animateIn(t)}}},{key:"close",value:function(t){var e=this.$el.children("li").eq(t);e.length&&e[0].classList.contains("active")&&("function"==typeof this.options.onCloseStart&&this.options.onCloseStart.call(this,e[0]),e[0].classList.remove("active"),this._animateOut(t))}}],[{key:"init",value:function(t,e){return _get(s.__proto__||Object.getPrototypeOf(s),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Collapsible}},{key:"defaults",get:function(){return e}}]),s}();M.Collapsible=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"collapsible","M_Collapsible")}(cash,M.anime),function(h,i){"use strict";var e={alignment:"left",autoFocus:!0,constrainWidth:!0,container:null,coverTrigger:!0,closeOnClick:!0,hover:!1,inDuration:150,outDuration:250,onOpenStart:null,onOpenEnd:null,onCloseStart:null,onCloseEnd:null,onItemClick:null},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return i.el.M_Dropdown=i,n._dropdowns.push(i),i.id=M.getIdFromTrigger(t),i.dropdownEl=document.getElementById(i.id),i.$dropdownEl=h(i.dropdownEl),i.options=h.extend({},n.defaults,e),i.isOpen=!1,i.isScrollable=!1,i.isTouchMoving=!1,i.focusedIndex=-1,i.filterQuery=[],i.options.container?h(i.options.container).append(i.dropdownEl):i.$el.after(i.dropdownEl),i._makeDropdownFocusable(),i._resetFilterQueryBound=i._resetFilterQuery.bind(i),i._handleDocumentClickBound=i._handleDocumentClick.bind(i),i._handleDocumentTouchmoveBound=i._handleDocumentTouchmove.bind(i),i._handleDropdownClickBound=i._handleDropdownClick.bind(i),i._handleDropdownKeydownBound=i._handleDropdownKeydown.bind(i),i._handleTriggerKeydownBound=i._handleTriggerKeydown.bind(i),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._resetDropdownStyles(),this._removeEventHandlers(),n._dropdowns.splice(n._dropdowns.indexOf(this),1),this.el.M_Dropdown=void 0}},{key:"_setupEventHandlers",value:function(){this.el.addEventListener("keydown",this._handleTriggerKeydownBound),this.dropdownEl.addEventListener("click",this._handleDropdownClickBound),this.options.hover?(this._handleMouseEnterBound=this._handleMouseEnter.bind(this),this.el.addEventListener("mouseenter",this._handleMouseEnterBound),this._handleMouseLeaveBound=this._handleMouseLeave.bind(this),this.el.addEventListener("mouseleave",this._handleMouseLeaveBound),this.dropdownEl.addEventListener("mouseleave",this._handleMouseLeaveBound)):(this._handleClickBound=this._handleClick.bind(this),this.el.addEventListener("click",this._handleClickBound))}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("keydown",this._handleTriggerKeydownBound),this.dropdownEl.removeEventListener("click",this._handleDropdownClickBound),this.options.hover?(this.el.removeEventListener("mouseenter",this._handleMouseEnterBound),this.el.removeEventListener("mouseleave",this._handleMouseLeaveBound),this.dropdownEl.removeEventListener("mouseleave",this._handleMouseLeaveBound)):this.el.removeEventListener("click",this._handleClickBound)}},{key:"_setupTemporaryEventHandlers",value:function(){document.body.addEventListener("click",this._handleDocumentClickBound,!0),document.body.addEventListener("touchend",this._handleDocumentClickBound),document.body.addEventListener("touchmove",this._handleDocumentTouchmoveBound),this.dropdownEl.addEventListener("keydown",this._handleDropdownKeydownBound)}},{key:"_removeTemporaryEventHandlers",value:function(){document.body.removeEventListener("click",this._handleDocumentClickBound,!0),document.body.removeEventListener("touchend",this._handleDocumentClickBound),document.body.removeEventListener("touchmove",this._handleDocumentTouchmoveBound),this.dropdownEl.removeEventListener("keydown",this._handleDropdownKeydownBound)}},{key:"_handleClick",value:function(t){t.preventDefault(),this.open()}},{key:"_handleMouseEnter",value:function(){this.open()}},{key:"_handleMouseLeave",value:function(t){var e=t.toElement||t.relatedTarget,i=!!h(e).closest(".dropdown-content").length,n=!1,s=h(e).closest(".dropdown-trigger");s.length&&s[0].M_Dropdown&&s[0].M_Dropdown.isOpen&&(n=!0),n||i||this.close()}},{key:"_handleDocumentClick",value:function(t){var e=this,i=h(t.target);this.options.closeOnClick&&i.closest(".dropdown-content").length&&!this.isTouchMoving?setTimeout(function(){e.close()},0):!i.closest(".dropdown-trigger").length&&i.closest(".dropdown-content").length||setTimeout(function(){e.close()},0),this.isTouchMoving=!1}},{key:"_handleTriggerKeydown",value:function(t){t.which!==M.keys.ARROW_DOWN&&t.which!==M.keys.ENTER||this.isOpen||(t.preventDefault(),this.open())}},{key:"_handleDocumentTouchmove",value:function(t){h(t.target).closest(".dropdown-content").length&&(this.isTouchMoving=!0)}},{key:"_handleDropdownClick",value:function(t){if("function"==typeof this.options.onItemClick){var e=h(t.target).closest("li")[0];this.options.onItemClick.call(this,e)}}},{key:"_handleDropdownKeydown",value:function(t){if(t.which===M.keys.TAB)t.preventDefault(),this.close();else if(t.which!==M.keys.ARROW_DOWN&&t.which!==M.keys.ARROW_UP||!this.isOpen)if(t.which===M.keys.ENTER&&this.isOpen){var e=this.dropdownEl.children[this.focusedIndex],i=h(e).find("a, button").first();i.length?i[0].click():e&&e.click()}else t.which===M.keys.ESC&&this.isOpen&&(t.preventDefault(),this.close());else{t.preventDefault();var n=t.which===M.keys.ARROW_DOWN?1:-1,s=this.focusedIndex,o=!1;do{if(s+=n,this.dropdownEl.children[s]&&-1!==this.dropdownEl.children[s].tabIndex){o=!0;break}}while(sl.spaceOnBottom?(h="bottom",i+=l.spaceOnTop,o-=l.spaceOnTop):i+=l.spaceOnBottom)),!l[d]){var u="left"===d?"right":"left";l[u]?d=u:l.spaceOnLeft>l.spaceOnRight?(d="right",n+=l.spaceOnLeft,s-=l.spaceOnLeft):(d="left",n+=l.spaceOnRight)}return"bottom"===h&&(o=o-e.height+(this.options.coverTrigger?t.height:0)),"right"===d&&(s=s-e.width+t.width),{x:s,y:o,verticalAlignment:h,horizontalAlignment:d,height:i,width:n}}},{key:"_animateIn",value:function(){var e=this;i.remove(this.dropdownEl),i({targets:this.dropdownEl,opacity:{value:[0,1],easing:"easeOutQuad"},scaleX:[.3,1],scaleY:[.3,1],duration:this.options.inDuration,easing:"easeOutQuint",complete:function(t){e.options.autoFocus&&e.dropdownEl.focus(),"function"==typeof e.options.onOpenEnd&&e.options.onOpenEnd.call(e,e.el)}})}},{key:"_animateOut",value:function(){var e=this;i.remove(this.dropdownEl),i({targets:this.dropdownEl,opacity:{value:0,easing:"easeOutQuint"},scaleX:.3,scaleY:.3,duration:this.options.outDuration,easing:"easeOutQuint",complete:function(t){e._resetDropdownStyles(),"function"==typeof e.options.onCloseEnd&&e.options.onCloseEnd.call(e,e.el)}})}},{key:"_placeDropdown",value:function(){var t=this.options.constrainWidth?this.el.getBoundingClientRect().width:this.dropdownEl.getBoundingClientRect().width;this.dropdownEl.style.width=t+"px";var e=this._getDropdownPosition();this.dropdownEl.style.left=e.x+"px",this.dropdownEl.style.top=e.y+"px",this.dropdownEl.style.height=e.height+"px",this.dropdownEl.style.width=e.width+"px",this.dropdownEl.style.transformOrigin=("left"===e.horizontalAlignment?"0":"100%")+" "+("top"===e.verticalAlignment?"0":"100%")}},{key:"open",value:function(){this.isOpen||(this.isOpen=!0,"function"==typeof this.options.onOpenStart&&this.options.onOpenStart.call(this,this.el),this._resetDropdownStyles(),this.dropdownEl.style.display="block",this._placeDropdown(),this._animateIn(),this._setupTemporaryEventHandlers())}},{key:"close",value:function(){this.isOpen&&(this.isOpen=!1,this.focusedIndex=-1,"function"==typeof this.options.onCloseStart&&this.options.onCloseStart.call(this,this.el),this._animateOut(),this._removeTemporaryEventHandlers(),this.options.autoFocus&&this.el.focus())}},{key:"recalculateDimensions",value:function(){this.isOpen&&(this.$dropdownEl.css({width:"",height:"",left:"",top:"","transform-origin":""}),this._placeDropdown())}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Dropdown}},{key:"defaults",get:function(){return e}}]),n}();t._dropdowns=[],M.Dropdown=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"dropdown","M_Dropdown")}(cash,M.anime),function(s,i){"use strict";var e={opacity:.5,inDuration:250,outDuration:250,onOpenStart:null,onOpenEnd:null,onCloseStart:null,onCloseEnd:null,preventScrolling:!0,dismissible:!0,startingTop:"4%",endingTop:"10%"},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_Modal=i).options=s.extend({},n.defaults,e),i.isOpen=!1,i.id=i.$el.attr("id"),i._openingTrigger=void 0,i.$overlay=s(''),i.el.tabIndex=0,i._nthModalOpened=0,n._count++,i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){n._count--,this._removeEventHandlers(),this.el.removeAttribute("style"),this.$overlay.remove(),this.el.M_Modal=void 0}},{key:"_setupEventHandlers",value:function(){this._handleOverlayClickBound=this._handleOverlayClick.bind(this),this._handleModalCloseClickBound=this._handleModalCloseClick.bind(this),1===n._count&&document.body.addEventListener("click",this._handleTriggerClick),this.$overlay[0].addEventListener("click",this._handleOverlayClickBound),this.el.addEventListener("click",this._handleModalCloseClickBound)}},{key:"_removeEventHandlers",value:function(){0===n._count&&document.body.removeEventListener("click",this._handleTriggerClick),this.$overlay[0].removeEventListener("click",this._handleOverlayClickBound),this.el.removeEventListener("click",this._handleModalCloseClickBound)}},{key:"_handleTriggerClick",value:function(t){var e=s(t.target).closest(".modal-trigger");if(e.length){var i=M.getIdFromTrigger(e[0]),n=document.getElementById(i).M_Modal;n&&n.open(e),t.preventDefault()}}},{key:"_handleOverlayClick",value:function(){this.options.dismissible&&this.close()}},{key:"_handleModalCloseClick",value:function(t){s(t.target).closest(".modal-close").length&&this.close()}},{key:"_handleKeydown",value:function(t){27===t.keyCode&&this.options.dismissible&&this.close()}},{key:"_handleFocus",value:function(t){this.el.contains(t.target)||this._nthModalOpened!==n._modalsOpen||this.el.focus()}},{key:"_animateIn",value:function(){var t=this;s.extend(this.el.style,{display:"block",opacity:0}),s.extend(this.$overlay[0].style,{display:"block",opacity:0}),i({targets:this.$overlay[0],opacity:this.options.opacity,duration:this.options.inDuration,easing:"easeOutQuad"});var e={targets:this.el,duration:this.options.inDuration,easing:"easeOutCubic",complete:function(){"function"==typeof t.options.onOpenEnd&&t.options.onOpenEnd.call(t,t.el,t._openingTrigger)}};this.el.classList.contains("bottom-sheet")?s.extend(e,{bottom:0,opacity:1}):s.extend(e,{top:[this.options.startingTop,this.options.endingTop],opacity:1,scaleX:[.8,1],scaleY:[.8,1]}),i(e)}},{key:"_animateOut",value:function(){var t=this;i({targets:this.$overlay[0],opacity:0,duration:this.options.outDuration,easing:"easeOutQuart"});var e={targets:this.el,duration:this.options.outDuration,easing:"easeOutCubic",complete:function(){t.el.style.display="none",t.$overlay.remove(),"function"==typeof t.options.onCloseEnd&&t.options.onCloseEnd.call(t,t.el)}};this.el.classList.contains("bottom-sheet")?s.extend(e,{bottom:"-100%",opacity:0}):s.extend(e,{top:[this.options.endingTop,this.options.startingTop],opacity:0,scaleX:.8,scaleY:.8}),i(e)}},{key:"open",value:function(t){if(!this.isOpen)return this.isOpen=!0,n._modalsOpen++,this._nthModalOpened=n._modalsOpen,this.$overlay[0].style.zIndex=1e3+2*n._modalsOpen,this.el.style.zIndex=1e3+2*n._modalsOpen+1,this._openingTrigger=t?t[0]:void 0,"function"==typeof this.options.onOpenStart&&this.options.onOpenStart.call(this,this.el,this._openingTrigger),this.options.preventScrolling&&(document.body.style.overflow="hidden"),this.el.classList.add("open"),this.el.insertAdjacentElement("afterend",this.$overlay[0]),this.options.dismissible&&(this._handleKeydownBound=this._handleKeydown.bind(this),this._handleFocusBound=this._handleFocus.bind(this),document.addEventListener("keydown",this._handleKeydownBound),document.addEventListener("focus",this._handleFocusBound,!0)),i.remove(this.el),i.remove(this.$overlay[0]),this._animateIn(),this.el.focus(),this}},{key:"close",value:function(){if(this.isOpen)return this.isOpen=!1,n._modalsOpen--,this._nthModalOpened=0,"function"==typeof this.options.onCloseStart&&this.options.onCloseStart.call(this,this.el),this.el.classList.remove("open"),0===n._modalsOpen&&(document.body.style.overflow=""),this.options.dismissible&&(document.removeEventListener("keydown",this._handleKeydownBound),document.removeEventListener("focus",this._handleFocusBound,!0)),i.remove(this.el),i.remove(this.$overlay[0]),this._animateOut(),this}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Modal}},{key:"defaults",get:function(){return e}}]),n}();t._modalsOpen=0,t._count=0,M.Modal=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"modal","M_Modal")}(cash,M.anime),function(o,a){"use strict";var e={inDuration:275,outDuration:200,onOpenStart:null,onOpenEnd:null,onCloseStart:null,onCloseEnd:null},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_Materialbox=i).options=o.extend({},n.defaults,e),i.overlayActive=!1,i.doneAnimating=!0,i.placeholder=o("
").addClass("material-placeholder"),i.originalWidth=0,i.originalHeight=0,i.originInlineStyles=i.$el.attr("style"),i.caption=i.el.getAttribute("data-caption")||"",i.$el.before(i.placeholder),i.placeholder.append(i.$el),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._removeEventHandlers(),this.el.M_Materialbox=void 0,o(this.placeholder).after(this.el).remove(),this.$el.removeAttr("style")}},{key:"_setupEventHandlers",value:function(){this._handleMaterialboxClickBound=this._handleMaterialboxClick.bind(this),this.el.addEventListener("click",this._handleMaterialboxClickBound)}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("click",this._handleMaterialboxClickBound)}},{key:"_handleMaterialboxClick",value:function(t){!1===this.doneAnimating||this.overlayActive&&this.doneAnimating?this.close():this.open()}},{key:"_handleWindowScroll",value:function(){this.overlayActive&&this.close()}},{key:"_handleWindowResize",value:function(){this.overlayActive&&this.close()}},{key:"_handleWindowEscape",value:function(t){27===t.keyCode&&this.doneAnimating&&this.overlayActive&&this.close()}},{key:"_makeAncestorsOverflowVisible",value:function(){this.ancestorsChanged=o();for(var t=this.placeholder[0].parentNode;null!==t&&!o(t).is(document);){var e=o(t);"visible"!==e.css("overflow")&&(e.css("overflow","visible"),void 0===this.ancestorsChanged?this.ancestorsChanged=e:this.ancestorsChanged=this.ancestorsChanged.add(e)),t=t.parentNode}}},{key:"_animateImageIn",value:function(){var t=this,e={targets:this.el,height:[this.originalHeight,this.newHeight],width:[this.originalWidth,this.newWidth],left:M.getDocumentScrollLeft()+this.windowWidth/2-this.placeholder.offset().left-this.newWidth/2,top:M.getDocumentScrollTop()+this.windowHeight/2-this.placeholder.offset().top-this.newHeight/2,duration:this.options.inDuration,easing:"easeOutQuad",complete:function(){t.doneAnimating=!0,"function"==typeof t.options.onOpenEnd&&t.options.onOpenEnd.call(t,t.el)}};this.maxWidth=this.$el.css("max-width"),this.maxHeight=this.$el.css("max-height"),"none"!==this.maxWidth&&(e.maxWidth=this.newWidth),"none"!==this.maxHeight&&(e.maxHeight=this.newHeight),a(e)}},{key:"_animateImageOut",value:function(){var t=this,e={targets:this.el,width:this.originalWidth,height:this.originalHeight,left:0,top:0,duration:this.options.outDuration,easing:"easeOutQuad",complete:function(){t.placeholder.css({height:"",width:"",position:"",top:"",left:""}),t.attrWidth&&t.$el.attr("width",t.attrWidth),t.attrHeight&&t.$el.attr("height",t.attrHeight),t.$el.removeAttr("style"),t.originInlineStyles&&t.$el.attr("style",t.originInlineStyles),t.$el.removeClass("active"),t.doneAnimating=!0,t.ancestorsChanged.length&&t.ancestorsChanged.css("overflow",""),"function"==typeof t.options.onCloseEnd&&t.options.onCloseEnd.call(t,t.el)}};a(e)}},{key:"_updateVars",value:function(){this.windowWidth=window.innerWidth,this.windowHeight=window.innerHeight,this.caption=this.el.getAttribute("data-caption")||""}},{key:"open",value:function(){var t=this;this._updateVars(),this.originalWidth=this.el.getBoundingClientRect().width,this.originalHeight=this.el.getBoundingClientRect().height,this.doneAnimating=!1,this.$el.addClass("active"),this.overlayActive=!0,"function"==typeof this.options.onOpenStart&&this.options.onOpenStart.call(this,this.el),this.placeholder.css({width:this.placeholder[0].getBoundingClientRect().width+"px",height:this.placeholder[0].getBoundingClientRect().height+"px",position:"relative",top:0,left:0}),this._makeAncestorsOverflowVisible(),this.$el.css({position:"absolute","z-index":1e3,"will-change":"left, top, width, height"}),this.attrWidth=this.$el.attr("width"),this.attrHeight=this.$el.attr("height"),this.attrWidth&&(this.$el.css("width",this.attrWidth+"px"),this.$el.removeAttr("width")),this.attrHeight&&(this.$el.css("width",this.attrHeight+"px"),this.$el.removeAttr("height")),this.$overlay=o('
').css({opacity:0}).one("click",function(){t.doneAnimating&&t.close()}),this.$el.before(this.$overlay);var e=this.$overlay[0].getBoundingClientRect();this.$overlay.css({width:this.windowWidth+"px",height:this.windowHeight+"px",left:-1*e.left+"px",top:-1*e.top+"px"}),a.remove(this.el),a.remove(this.$overlay[0]),a({targets:this.$overlay[0],opacity:1,duration:this.options.inDuration,easing:"easeOutQuad"}),""!==this.caption&&(this.$photocaption&&a.remove(this.$photoCaption[0]),this.$photoCaption=o('
'),this.$photoCaption.text(this.caption),o("body").append(this.$photoCaption),this.$photoCaption.css({display:"inline"}),a({targets:this.$photoCaption[0],opacity:1,duration:this.options.inDuration,easing:"easeOutQuad"}));var i=0,n=this.originalWidth/this.windowWidth,s=this.originalHeight/this.windowHeight;this.newWidth=0,this.newHeight=0,si.options.responsiveThreshold,i.$img=i.$el.find("img").first(),i.$img.each(function(){this.complete&&s(this).trigger("load")}),i._updateParallax(),i._setupEventHandlers(),i._setupStyles(),n._parallaxes.push(i),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){n._parallaxes.splice(n._parallaxes.indexOf(this),1),this.$img[0].style.transform="",this._removeEventHandlers(),this.$el[0].M_Parallax=void 0}},{key:"_setupEventHandlers",value:function(){this._handleImageLoadBound=this._handleImageLoad.bind(this),this.$img[0].addEventListener("load",this._handleImageLoadBound),0===n._parallaxes.length&&(n._handleScrollThrottled=M.throttle(n._handleScroll,5),window.addEventListener("scroll",n._handleScrollThrottled),n._handleWindowResizeThrottled=M.throttle(n._handleWindowResize,5),window.addEventListener("resize",n._handleWindowResizeThrottled))}},{key:"_removeEventHandlers",value:function(){this.$img[0].removeEventListener("load",this._handleImageLoadBound),0===n._parallaxes.length&&(window.removeEventListener("scroll",n._handleScrollThrottled),window.removeEventListener("resize",n._handleWindowResizeThrottled))}},{key:"_setupStyles",value:function(){this.$img[0].style.opacity=1}},{key:"_handleImageLoad",value:function(){this._updateParallax()}},{key:"_updateParallax",value:function(){var t=0e.options.responsiveThreshold}}},{key:"defaults",get:function(){return e}}]),n}();t._parallaxes=[],M.Parallax=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"parallax","M_Parallax")}(cash),function(a,s){"use strict";var e={duration:300,onShow:null,swipeable:!1,responsiveThreshold:1/0},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_Tabs=i).options=a.extend({},n.defaults,e),i.$tabLinks=i.$el.children("li.tab").children("a"),i.index=0,i._setupActiveTabLink(),i.options.swipeable?i._setupSwipeableTabs():i._setupNormalTabs(),i._setTabsAndTabWidth(),i._createIndicator(),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._removeEventHandlers(),this._indicator.parentNode.removeChild(this._indicator),this.options.swipeable?this._teardownSwipeableTabs():this._teardownNormalTabs(),this.$el[0].M_Tabs=void 0}},{key:"_setupEventHandlers",value:function(){this._handleWindowResizeBound=this._handleWindowResize.bind(this),window.addEventListener("resize",this._handleWindowResizeBound),this._handleTabClickBound=this._handleTabClick.bind(this),this.el.addEventListener("click",this._handleTabClickBound)}},{key:"_removeEventHandlers",value:function(){window.removeEventListener("resize",this._handleWindowResizeBound),this.el.removeEventListener("click",this._handleTabClickBound)}},{key:"_handleWindowResize",value:function(){this._setTabsAndTabWidth(),0!==this.tabWidth&&0!==this.tabsWidth&&(this._indicator.style.left=this._calcLeftPos(this.$activeTabLink)+"px",this._indicator.style.right=this._calcRightPos(this.$activeTabLink)+"px")}},{key:"_handleTabClick",value:function(t){var e=this,i=a(t.target).closest("li.tab"),n=a(t.target).closest("a");if(n.length&&n.parent().hasClass("tab"))if(i.hasClass("disabled"))t.preventDefault();else if(!n.attr("target")){this.$activeTabLink.removeClass("active");var s=this.$content;this.$activeTabLink=n,this.$content=a(M.escapeHash(n[0].hash)),this.$tabLinks=this.$el.children("li.tab").children("a"),this.$activeTabLink.addClass("active");var o=this.index;this.index=Math.max(this.$tabLinks.index(n),0),this.options.swipeable?this._tabsCarousel&&this._tabsCarousel.set(this.index,function(){"function"==typeof e.options.onShow&&e.options.onShow.call(e,e.$content[0])}):this.$content.length&&(this.$content[0].style.display="block",this.$content.addClass("active"),"function"==typeof this.options.onShow&&this.options.onShow.call(this,this.$content[0]),s.length&&!s.is(this.$content)&&(s[0].style.display="none",s.removeClass("active"))),this._setTabsAndTabWidth(),this._animateIndicator(o),t.preventDefault()}}},{key:"_createIndicator",value:function(){var t=this,e=document.createElement("li");e.classList.add("indicator"),this.el.appendChild(e),this._indicator=e,setTimeout(function(){t._indicator.style.left=t._calcLeftPos(t.$activeTabLink)+"px",t._indicator.style.right=t._calcRightPos(t.$activeTabLink)+"px"},0)}},{key:"_setupActiveTabLink",value:function(){this.$activeTabLink=a(this.$tabLinks.filter('[href="'+location.hash+'"]')),0===this.$activeTabLink.length&&(this.$activeTabLink=this.$el.children("li.tab").children("a.active").first()),0===this.$activeTabLink.length&&(this.$activeTabLink=this.$el.children("li.tab").children("a").first()),this.$tabLinks.removeClass("active"),this.$activeTabLink[0].classList.add("active"),this.index=Math.max(this.$tabLinks.index(this.$activeTabLink),0),this.$activeTabLink.length&&(this.$content=a(M.escapeHash(this.$activeTabLink[0].hash)),this.$content.addClass("active"))}},{key:"_setupSwipeableTabs",value:function(){var i=this;window.innerWidth>this.options.responsiveThreshold&&(this.options.swipeable=!1);var n=a();this.$tabLinks.each(function(t){var e=a(M.escapeHash(t.hash));e.addClass("carousel-item"),n=n.add(e)});var t=a('');n.first().before(t),t.append(n),n[0].style.display="";var e=this.$activeTabLink.closest(".tab").index();this._tabsCarousel=M.Carousel.init(t[0],{fullWidth:!0,noWrap:!0,onCycleTo:function(t){var e=i.index;i.index=a(t).index(),i.$activeTabLink.removeClass("active"),i.$activeTabLink=i.$tabLinks.eq(i.index),i.$activeTabLink.addClass("active"),i._animateIndicator(e),"function"==typeof i.options.onShow&&i.options.onShow.call(i,i.$content[0])}}),this._tabsCarousel.set(e)}},{key:"_teardownSwipeableTabs",value:function(){var t=this._tabsCarousel.$el;this._tabsCarousel.destroy(),t.after(t.children()),t.remove()}},{key:"_setupNormalTabs",value:function(){this.$tabLinks.not(this.$activeTabLink).each(function(t){if(t.hash){var e=a(M.escapeHash(t.hash));e.length&&(e[0].style.display="none")}})}},{key:"_teardownNormalTabs",value:function(){this.$tabLinks.each(function(t){if(t.hash){var e=a(M.escapeHash(t.hash));e.length&&(e[0].style.display="")}})}},{key:"_setTabsAndTabWidth",value:function(){this.tabsWidth=this.$el.width(),this.tabWidth=Math.max(this.tabsWidth,this.el.scrollWidth)/this.$tabLinks.length}},{key:"_calcRightPos",value:function(t){return Math.ceil(this.tabsWidth-t.position().left-t[0].getBoundingClientRect().width)}},{key:"_calcLeftPos",value:function(t){return Math.floor(t.position().left)}},{key:"updateTabIndicator",value:function(){this._setTabsAndTabWidth(),this._animateIndicator(this.index)}},{key:"_animateIndicator",value:function(t){var e=0,i=0;0<=this.index-t?e=90:i=90;var n={targets:this._indicator,left:{value:this._calcLeftPos(this.$activeTabLink),delay:e},right:{value:this._calcRightPos(this.$activeTabLink),delay:i},duration:this.options.duration,easing:"easeOutQuad"};s.remove(this._indicator),s(n)}},{key:"select",value:function(t){var e=this.$tabLinks.filter('[href="#'+t+'"]');e.length&&e.trigger("click")}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Tabs}},{key:"defaults",get:function(){return e}}]),n}();M.Tabs=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"tabs","M_Tabs")}(cash,M.anime),function(d,e){"use strict";var i={exitDelay:200,enterDelay:0,html:null,margin:5,inDuration:250,outDuration:200,position:"bottom",transitionMovement:10},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_Tooltip=i).options=d.extend({},n.defaults,e),i.isOpen=!1,i.isHovered=!1,i.isFocused=!1,i._appendTooltipEl(),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){d(this.tooltipEl).remove(),this._removeEventHandlers(),this.el.M_Tooltip=void 0}},{key:"_appendTooltipEl",value:function(){var t=document.createElement("div");t.classList.add("material-tooltip"),this.tooltipEl=t;var e=document.createElement("div");e.classList.add("tooltip-content"),e.innerHTML=this.options.html,t.appendChild(e),document.body.appendChild(t)}},{key:"_updateTooltipContent",value:function(){this.tooltipEl.querySelector(".tooltip-content").innerHTML=this.options.html}},{key:"_setupEventHandlers",value:function(){this._handleMouseEnterBound=this._handleMouseEnter.bind(this),this._handleMouseLeaveBound=this._handleMouseLeave.bind(this),this._handleFocusBound=this._handleFocus.bind(this),this._handleBlurBound=this._handleBlur.bind(this),this.el.addEventListener("mouseenter",this._handleMouseEnterBound),this.el.addEventListener("mouseleave",this._handleMouseLeaveBound),this.el.addEventListener("focus",this._handleFocusBound,!0),this.el.addEventListener("blur",this._handleBlurBound,!0)}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("mouseenter",this._handleMouseEnterBound),this.el.removeEventListener("mouseleave",this._handleMouseLeaveBound),this.el.removeEventListener("focus",this._handleFocusBound,!0),this.el.removeEventListener("blur",this._handleBlurBound,!0)}},{key:"open",value:function(t){this.isOpen||(t=void 0===t||void 0,this.isOpen=!0,this.options=d.extend({},this.options,this._getAttributeOptions()),this._updateTooltipContent(),this._setEnterDelayTimeout(t))}},{key:"close",value:function(){this.isOpen&&(this.isHovered=!1,this.isFocused=!1,this.isOpen=!1,this._setExitDelayTimeout())}},{key:"_setExitDelayTimeout",value:function(){var t=this;clearTimeout(this._exitDelayTimeout),this._exitDelayTimeout=setTimeout(function(){t.isHovered||t.isFocused||t._animateOut()},this.options.exitDelay)}},{key:"_setEnterDelayTimeout",value:function(t){var e=this;clearTimeout(this._enterDelayTimeout),this._enterDelayTimeout=setTimeout(function(){(e.isHovered||e.isFocused||t)&&e._animateIn()},this.options.enterDelay)}},{key:"_positionTooltip",value:function(){var t,e=this.el,i=this.tooltipEl,n=e.offsetHeight,s=e.offsetWidth,o=i.offsetHeight,a=i.offsetWidth,r=this.options.margin,l=void 0,h=void 0;this.xMovement=0,this.yMovement=0,l=e.getBoundingClientRect().top+M.getDocumentScrollTop(),h=e.getBoundingClientRect().left+M.getDocumentScrollLeft(),"top"===this.options.position?(l+=-o-r,h+=s/2-a/2,this.yMovement=-this.options.transitionMovement):"right"===this.options.position?(l+=n/2-o/2,h+=s+r,this.xMovement=this.options.transitionMovement):"left"===this.options.position?(l+=n/2-o/2,h+=-a-r,this.xMovement=-this.options.transitionMovement):(l+=n+r,h+=s/2-a/2,this.yMovement=this.options.transitionMovement),t=this._repositionWithinScreen(h,l,a,o),d(i).css({top:t.y+"px",left:t.x+"px"})}},{key:"_repositionWithinScreen",value:function(t,e,i,n){var s=M.getDocumentScrollLeft(),o=M.getDocumentScrollTop(),a=t-s,r=e-o,l={left:a,top:r,width:i,height:n},h=this.options.margin+this.options.transitionMovement,d=M.checkWithinContainer(document.body,l,h);return d.left?a=h:d.right&&(a-=a+i-window.innerWidth),d.top?r=h:d.bottom&&(r-=r+n-window.innerHeight),{x:a+s,y:r+o}}},{key:"_animateIn",value:function(){this._positionTooltip(),this.tooltipEl.style.visibility="visible",e.remove(this.tooltipEl),e({targets:this.tooltipEl,opacity:1,translateX:this.xMovement,translateY:this.yMovement,duration:this.options.inDuration,easing:"easeOutCubic"})}},{key:"_animateOut",value:function(){e.remove(this.tooltipEl),e({targets:this.tooltipEl,opacity:0,translateX:0,translateY:0,duration:this.options.outDuration,easing:"easeOutCubic"})}},{key:"_handleMouseEnter",value:function(){this.isHovered=!0,this.isFocused=!1,this.open(!1)}},{key:"_handleMouseLeave",value:function(){this.isHovered=!1,this.isFocused=!1,this.close()}},{key:"_handleFocus",value:function(){M.tabPressed&&(this.isFocused=!0,this.open(!1))}},{key:"_handleBlur",value:function(){this.isFocused=!1,this.close()}},{key:"_getAttributeOptions",value:function(){var t={},e=this.el.getAttribute("data-tooltip"),i=this.el.getAttribute("data-position");return e&&(t.html=e),i&&(t.position=i),t}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Tooltip}},{key:"defaults",get:function(){return i}}]),n}();M.Tooltip=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"tooltip","M_Tooltip")}(cash,M.anime),function(i){"use strict";var t=t||{},e=document.querySelectorAll.bind(document);function m(t){var e="";for(var i in t)t.hasOwnProperty(i)&&(e+=i+":"+t[i]+";");return e}var g={duration:750,show:function(t,e){if(2===t.button)return!1;var i=e||this,n=document.createElement("div");n.className="waves-ripple",i.appendChild(n);var s,o,a,r,l,h,d,u=(h={top:0,left:0},d=(s=i)&&s.ownerDocument,o=d.documentElement,void 0!==s.getBoundingClientRect&&(h=s.getBoundingClientRect()),a=null!==(l=r=d)&&l===l.window?r:9===r.nodeType&&r.defaultView,{top:h.top+a.pageYOffset-o.clientTop,left:h.left+a.pageXOffset-o.clientLeft}),c=t.pageY-u.top,p=t.pageX-u.left,v="scale("+i.clientWidth/100*10+")";"touches"in t&&(c=t.touches[0].pageY-u.top,p=t.touches[0].pageX-u.left),n.setAttribute("data-hold",Date.now()),n.setAttribute("data-scale",v),n.setAttribute("data-x",p),n.setAttribute("data-y",c);var f={top:c+"px",left:p+"px"};n.className=n.className+" waves-notransition",n.setAttribute("style",m(f)),n.className=n.className.replace("waves-notransition",""),f["-webkit-transform"]=v,f["-moz-transform"]=v,f["-ms-transform"]=v,f["-o-transform"]=v,f.transform=v,f.opacity="1",f["-webkit-transition-duration"]=g.duration+"ms",f["-moz-transition-duration"]=g.duration+"ms",f["-o-transition-duration"]=g.duration+"ms",f["transition-duration"]=g.duration+"ms",f["-webkit-transition-timing-function"]="cubic-bezier(0.250, 0.460, 0.450, 0.940)",f["-moz-transition-timing-function"]="cubic-bezier(0.250, 0.460, 0.450, 0.940)",f["-o-transition-timing-function"]="cubic-bezier(0.250, 0.460, 0.450, 0.940)",f["transition-timing-function"]="cubic-bezier(0.250, 0.460, 0.450, 0.940)",n.setAttribute("style",m(f))},hide:function(t){l.touchup(t);var e=this,i=(e.clientWidth,null),n=e.getElementsByClassName("waves-ripple");if(!(0i||1"+o+""+a+""+r+""),i.length&&e.prepend(i)}},{key:"_resetCurrentElement",value:function(){this.activeIndex=-1,this.$active.removeClass("active")}},{key:"_resetAutocomplete",value:function(){h(this.container).empty(),this._resetCurrentElement(),this.oldVal=null,this.isOpen=!1,this._mousedown=!1}},{key:"selectOption",value:function(t){var e=t.text().trim();this.el.value=e,this.$el.trigger("change"),this._resetAutocomplete(),this.close(),"function"==typeof this.options.onAutocomplete&&this.options.onAutocomplete.call(this,e)}},{key:"_renderDropdown",value:function(t,i){var n=this;this._resetAutocomplete();var e=[];for(var s in t)if(t.hasOwnProperty(s)&&-1!==s.toLowerCase().indexOf(i)){if(this.count>=this.options.limit)break;var o={data:t[s],key:s};e.push(o),this.count++}if(this.options.sortFunction){e.sort(function(t,e){return n.options.sortFunction(t.key.toLowerCase(),e.key.toLowerCase(),i.toLowerCase())})}for(var a=0;a");r.data?l.append(''+r.key+""):l.append(""+r.key+""),h(this.container).append(l),this._highlight(i,l)}}},{key:"open",value:function(){var t=this.el.value.toLowerCase();this._resetAutocomplete(),t.length>=this.options.minLength&&(this.isOpen=!0,this._renderDropdown(this.options.data,t)),this.dropdown.isOpen?this.dropdown.recalculateDimensions():this.dropdown.open()}},{key:"close",value:function(){this.dropdown.close()}},{key:"updateData",value:function(t){var e=this.el.value.toLowerCase();this.options.data=t,this.isOpen&&this._renderDropdown(t,e)}}],[{key:"init",value:function(t,e){return _get(s.__proto__||Object.getPrototypeOf(s),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Autocomplete}},{key:"defaults",get:function(){return e}}]),s}();t._keydown=!1,M.Autocomplete=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"autocomplete","M_Autocomplete")}(cash),function(d){M.updateTextFields=function(){d("input[type=text], input[type=password], input[type=email], input[type=url], input[type=tel], input[type=number], input[type=search], input[type=date], input[type=time], textarea").each(function(t,e){var i=d(this);0'),d("body").append(e));var i=t.css("font-family"),n=t.css("font-size"),s=t.css("line-height"),o=t.css("padding-top"),a=t.css("padding-right"),r=t.css("padding-bottom"),l=t.css("padding-left");n&&e.css("font-size",n),i&&e.css("font-family",i),s&&e.css("line-height",s),o&&e.css("padding-top",o),a&&e.css("padding-right",a),r&&e.css("padding-bottom",r),l&&e.css("padding-left",l),t.data("original-height")||t.data("original-height",t.height()),"off"===t.attr("wrap")&&e.css("overflow-wrap","normal").css("white-space","pre"),e.text(t[0].value+"\n");var h=e.html().replace(/\n/g,"
");e.html(h),0'),this.$slides.each(function(t,e){var i=s('
  • ');n.$indicators.append(i[0])}),this.$el.append(this.$indicators[0]),this.$indicators=this.$indicators.children("li.indicator-item"))}},{key:"_removeIndicators",value:function(){this.$el.find("ul.indicators").remove()}},{key:"set",value:function(t){var e=this;if(t>=this.$slides.length?t=0:t<0&&(t=this.$slides.length-1),this.activeIndex!=t){this.$active=this.$slides.eq(this.activeIndex);var i=this.$active.find(".caption");this.$active.removeClass("active"),o({targets:this.$active[0],opacity:0,duration:this.options.duration,easing:"easeOutQuad",complete:function(){e.$slides.not(".active").each(function(t){o({targets:t,opacity:0,translateX:0,translateY:0,duration:0,easing:"easeOutQuad"})})}}),this._animateCaptionIn(i[0],this.options.duration),this.options.indicators&&(this.$indicators.eq(this.activeIndex).removeClass("active"),this.$indicators.eq(t).addClass("active")),o({targets:this.$slides.eq(t)[0],opacity:1,duration:this.options.duration,easing:"easeOutQuad"}),o({targets:this.$slides.eq(t).find(".caption")[0],opacity:1,translateX:0,translateY:0,duration:this.options.duration,delay:this.options.duration,easing:"easeOutQuad"}),this.$slides.eq(t).addClass("active"),this.activeIndex=t,this.start()}}},{key:"pause",value:function(){clearInterval(this.interval)}},{key:"start",value:function(){clearInterval(this.interval),this.interval=setInterval(this._handleIntervalBound,this.options.duration+this.options.interval)}},{key:"next",value:function(){var t=this.activeIndex+1;t>=this.$slides.length?t=0:t<0&&(t=this.$slides.length-1),this.set(t)}},{key:"prev",value:function(){var t=this.activeIndex-1;t>=this.$slides.length?t=0:t<0&&(t=this.$slides.length-1),this.set(t)}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Slider}},{key:"defaults",get:function(){return e}}]),n}();M.Slider=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"slider","M_Slider")}(cash,M.anime),function(n,s){n(document).on("click",".card",function(t){if(n(this).children(".card-reveal").length){var i=n(t.target).closest(".card");void 0===i.data("initialOverflow")&&i.data("initialOverflow",void 0===i.css("overflow")?"":i.css("overflow"));var e=n(this).find(".card-reveal");n(t.target).is(n(".card-reveal .card-title"))||n(t.target).is(n(".card-reveal .card-title i"))?s({targets:e[0],translateY:0,duration:225,easing:"easeInOutQuad",complete:function(t){var e=t.animatables[0].target;n(e).css({display:"none"}),i.css("overflow",i.data("initialOverflow"))}}):(n(t.target).is(n(".card .activator"))||n(t.target).is(n(".card .activator i")))&&(i.css("overflow","hidden"),e.css({display:"block"}),s({targets:e[0],translateY:"-100%",duration:300,easing:"easeInOutQuad"}))}})}(cash,M.anime),function(h){"use strict";var e={data:[],placeholder:"",secondaryPlaceholder:"",autocompleteOptions:{},limit:1/0,onChipAdd:null,onChipSelect:null,onChipDelete:null},t=function(t){function l(t,e){_classCallCheck(this,l);var i=_possibleConstructorReturn(this,(l.__proto__||Object.getPrototypeOf(l)).call(this,l,t,e));return(i.el.M_Chips=i).options=h.extend({},l.defaults,e),i.$el.addClass("chips input-field"),i.chipsData=[],i.$chips=h(),i._setupInput(),i.hasAutocomplete=0"),this.$el.append(this.$input)),this.$input.addClass("input")}},{key:"_setupLabel",value:function(){this.$label=this.$el.find("label"),this.$label.length&&this.$label.setAttribute("for",this.$input.attr("id"))}},{key:"_setPlaceholder",value:function(){void 0!==this.chipsData&&!this.chipsData.length&&this.options.placeholder?h(this.$input).prop("placeholder",this.options.placeholder):(void 0===this.chipsData||this.chipsData.length)&&this.options.secondaryPlaceholder&&h(this.$input).prop("placeholder",this.options.secondaryPlaceholder)}},{key:"_isValid",value:function(t){if(t.hasOwnProperty("tag")&&""!==t.tag){for(var e=!1,i=0;i=this.options.limit)){var e=this._renderChip(t);this.$chips.add(e),this.chipsData.push(t),h(this.$input).before(e),this._setPlaceholder(),"function"==typeof this.options.onChipAdd&&this.options.onChipAdd.call(this,this.$el,e)}}},{key:"deleteChip",value:function(t){var e=this.$chips.eq(t);this.$chips.eq(t).remove(),this.$chips=this.$chips.filter(function(t){return 0<=h(t).index()}),this.chipsData.splice(t,1),this._setPlaceholder(),"function"==typeof this.options.onChipDelete&&this.options.onChipDelete.call(this,this.$el,e[0])}},{key:"selectChip",value:function(t){var e=this.$chips.eq(t);(this._selectedChip=e)[0].focus(),"function"==typeof this.options.onChipSelect&&this.options.onChipSelect.call(this,this.$el,e[0])}}],[{key:"init",value:function(t,e){return _get(l.__proto__||Object.getPrototypeOf(l),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Chips}},{key:"_handleChipsKeydown",value:function(t){l._keydown=!0;var e=h(t.target).closest(".chips"),i=t.target&&e.length;if(!h(t.target).is("input, textarea")&&i){var n=e[0].M_Chips;if(8===t.keyCode||46===t.keyCode){t.preventDefault();var s=n.chipsData.length;if(n._selectedChip){var o=n._selectedChip.index();n.deleteChip(o),n._selectedChip=null,s=Math.max(o-1,0)}n.chipsData.length&&n.selectChip(s)}else if(37===t.keyCode){if(n._selectedChip){var a=n._selectedChip.index()-1;if(a<0)return;n.selectChip(a)}}else if(39===t.keyCode&&n._selectedChip){var r=n._selectedChip.index()+1;r>=n.chipsData.length?n.$input[0].focus():n.selectChip(r)}}}},{key:"_handleChipsKeyup",value:function(t){l._keydown=!1}},{key:"_handleChipsBlur",value:function(t){l._keydown||(h(t.target).closest(".chips")[0].M_Chips._selectedChip=null)}},{key:"defaults",get:function(){return e}}]),l}();t._keydown=!1,M.Chips=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"chips","M_Chips"),h(document).ready(function(){h(document.body).on("click",".chip .close",function(){var t=h(this).closest(".chips");t.length&&t[0].M_Chips||h(this).closest(".chip").remove()})})}(cash),function(s){"use strict";var e={top:0,bottom:1/0,offset:0,onPositionChange:null},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_Pushpin=i).options=s.extend({},n.defaults,e),i.originalOffset=i.el.offsetTop,n._pushpins.push(i),i._setupEventHandlers(),i._updatePosition(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this.el.style.top=null,this._removePinClasses(),this._removeEventHandlers();var t=n._pushpins.indexOf(this);n._pushpins.splice(t,1)}},{key:"_setupEventHandlers",value:function(){document.addEventListener("scroll",n._updateElements)}},{key:"_removeEventHandlers",value:function(){document.removeEventListener("scroll",n._updateElements)}},{key:"_updatePosition",value:function(){var t=M.getDocumentScrollTop()+this.options.offset;this.options.top<=t&&this.options.bottom>=t&&!this.el.classList.contains("pinned")&&(this._removePinClasses(),this.el.style.top=this.options.offset+"px",this.el.classList.add("pinned"),"function"==typeof this.options.onPositionChange&&this.options.onPositionChange.call(this,"pinned")),tthis.options.bottom&&!this.el.classList.contains("pin-bottom")&&(this._removePinClasses(),this.el.classList.add("pin-bottom"),this.el.style.top=this.options.bottom-this.originalOffset+"px","function"==typeof this.options.onPositionChange&&this.options.onPositionChange.call(this,"pin-bottom"))}},{key:"_removePinClasses",value:function(){this.el.classList.remove("pin-top"),this.el.classList.remove("pinned"),this.el.classList.remove("pin-bottom")}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Pushpin}},{key:"_updateElements",value:function(){for(var t in n._pushpins){n._pushpins[t]._updatePosition()}}},{key:"defaults",get:function(){return e}}]),n}();t._pushpins=[],M.Pushpin=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"pushpin","M_Pushpin")}(cash),function(r,s){"use strict";var e={direction:"top",hoverEnabled:!0,toolbarEnabled:!1};r.fn.reverse=[].reverse;var t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_FloatingActionButton=i).options=r.extend({},n.defaults,e),i.isOpen=!1,i.$anchor=i.$el.children("a").first(),i.$menu=i.$el.children("ul").first(),i.$floatingBtns=i.$el.find("ul .btn-floating"),i.$floatingBtnsReverse=i.$el.find("ul .btn-floating").reverse(),i.offsetY=0,i.offsetX=0,i.$el.addClass("direction-"+i.options.direction),"top"===i.options.direction?i.offsetY=40:"right"===i.options.direction?i.offsetX=-40:"bottom"===i.options.direction?i.offsetY=-40:i.offsetX=40,i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._removeEventHandlers(),this.el.M_FloatingActionButton=void 0}},{key:"_setupEventHandlers",value:function(){this._handleFABClickBound=this._handleFABClick.bind(this),this._handleOpenBound=this.open.bind(this),this._handleCloseBound=this.close.bind(this),this.options.hoverEnabled&&!this.options.toolbarEnabled?(this.el.addEventListener("mouseenter",this._handleOpenBound),this.el.addEventListener("mouseleave",this._handleCloseBound)):this.el.addEventListener("click",this._handleFABClickBound)}},{key:"_removeEventHandlers",value:function(){this.options.hoverEnabled&&!this.options.toolbarEnabled?(this.el.removeEventListener("mouseenter",this._handleOpenBound),this.el.removeEventListener("mouseleave",this._handleCloseBound)):this.el.removeEventListener("click",this._handleFABClickBound)}},{key:"_handleFABClick",value:function(){this.isOpen?this.close():this.open()}},{key:"_handleDocumentClick",value:function(t){r(t.target).closest(this.$menu).length||this.close()}},{key:"open",value:function(){this.isOpen||(this.options.toolbarEnabled?this._animateInToolbar():this._animateInFAB(),this.isOpen=!0)}},{key:"close",value:function(){this.isOpen&&(this.options.toolbarEnabled?(window.removeEventListener("scroll",this._handleCloseBound,!0),document.body.removeEventListener("click",this._handleDocumentClickBound,!0),this._animateOutToolbar()):this._animateOutFAB(),this.isOpen=!1)}},{key:"_animateInFAB",value:function(){var e=this;this.$el.addClass("active");var i=0;this.$floatingBtnsReverse.each(function(t){s({targets:t,opacity:1,scale:[.4,1],translateY:[e.offsetY,0],translateX:[e.offsetX,0],duration:275,delay:i,easing:"easeInOutQuad"}),i+=40})}},{key:"_animateOutFAB",value:function(){var e=this;this.$floatingBtnsReverse.each(function(t){s.remove(t),s({targets:t,opacity:0,scale:.4,translateY:e.offsetY,translateX:e.offsetX,duration:175,easing:"easeOutQuad",complete:function(){e.$el.removeClass("active")}})})}},{key:"_animateInToolbar",value:function(){var t,e=this,i=window.innerWidth,n=window.innerHeight,s=this.el.getBoundingClientRect(),o=r('
    '),a=this.$anchor.css("background-color");this.$anchor.append(o),this.offsetX=s.left-i/2+s.width/2,this.offsetY=n-s.bottom,t=i/o[0].clientWidth,this.btnBottom=s.bottom,this.btnLeft=s.left,this.btnWidth=s.width,this.$el.addClass("active"),this.$el.css({"text-align":"center",width:"100%",bottom:0,left:0,transform:"translateX("+this.offsetX+"px)",transition:"none"}),this.$anchor.css({transform:"translateY("+-this.offsetY+"px)",transition:"none"}),o.css({"background-color":a}),setTimeout(function(){e.$el.css({transform:"",transition:"transform .2s cubic-bezier(0.550, 0.085, 0.680, 0.530), background-color 0s linear .2s"}),e.$anchor.css({overflow:"visible",transform:"",transition:"transform .2s"}),setTimeout(function(){e.$el.css({overflow:"hidden","background-color":a}),o.css({transform:"scale("+t+")",transition:"transform .2s cubic-bezier(0.550, 0.055, 0.675, 0.190)"}),e.$menu.children("li").children("a").css({opacity:1}),e._handleDocumentClickBound=e._handleDocumentClick.bind(e),window.addEventListener("scroll",e._handleCloseBound,!0),document.body.addEventListener("click",e._handleDocumentClickBound,!0)},100)},0)}},{key:"_animateOutToolbar",value:function(){var t=this,e=window.innerWidth,i=window.innerHeight,n=this.$el.find(".fab-backdrop"),s=this.$anchor.css("background-color");this.offsetX=this.btnLeft-e/2+this.btnWidth/2,this.offsetY=i-this.btnBottom,this.$el.removeClass("active"),this.$el.css({"background-color":"transparent",transition:"none"}),this.$anchor.css({transition:"none"}),n.css({transform:"scale(0)","background-color":s}),this.$menu.children("li").children("a").css({opacity:""}),setTimeout(function(){n.remove(),t.$el.css({"text-align":"",width:"",bottom:"",left:"",overflow:"","background-color":"",transform:"translate3d("+-t.offsetX+"px,0,0)"}),t.$anchor.css({overflow:"",transform:"translate3d(0,"+t.offsetY+"px,0)"}),setTimeout(function(){t.$el.css({transform:"translate3d(0,0,0)",transition:"transform .2s"}),t.$anchor.css({transform:"translate3d(0,0,0)",transition:"transform .2s cubic-bezier(0.550, 0.055, 0.675, 0.190)"})},20)},200)}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_FloatingActionButton}},{key:"defaults",get:function(){return e}}]),n}();M.FloatingActionButton=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"floatingActionButton","M_FloatingActionButton")}(cash,M.anime),function(g){"use strict";var e={autoClose:!1,format:"mmm dd, yyyy",parse:null,defaultDate:null,setDefaultDate:!1,disableWeekends:!1,disableDayFn:null,firstDay:0,minDate:null,maxDate:null,yearRange:10,minYear:0,maxYear:9999,minMonth:void 0,maxMonth:void 0,startRange:null,endRange:null,isRTL:!1,showMonthAfterYear:!1,showDaysInNextAndPreviousMonths:!1,container:null,showClearBtn:!1,i18n:{cancel:"Cancel",clear:"Clear",done:"Ok",previousMonth:"‹",nextMonth:"›",months:["January","February","March","April","May","June","July","August","September","October","November","December"],monthsShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],weekdays:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],weekdaysShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],weekdaysAbbrev:["S","M","T","W","T","F","S"]},events:[],onSelect:null,onOpen:null,onClose:null,onDraw:null},t=function(t){function B(t,e){_classCallCheck(this,B);var i=_possibleConstructorReturn(this,(B.__proto__||Object.getPrototypeOf(B)).call(this,B,t,e));(i.el.M_Datepicker=i).options=g.extend({},B.defaults,e),e&&e.hasOwnProperty("i18n")&&"object"==typeof e.i18n&&(i.options.i18n=g.extend({},B.defaults.i18n,e.i18n)),i.options.minDate&&i.options.minDate.setHours(0,0,0,0),i.options.maxDate&&i.options.maxDate.setHours(0,0,0,0),i.id=M.guid(),i._setupVariables(),i._insertHTMLIntoDOM(),i._setupModal(),i._setupEventHandlers(),i.options.defaultDate||(i.options.defaultDate=new Date(Date.parse(i.el.value)));var n=i.options.defaultDate;return B._isDate(n)?i.options.setDefaultDate?(i.setDate(n,!0),i.setInputValue()):i.gotoDate(n):i.gotoDate(new Date),i.isOpen=!1,i}return _inherits(B,Component),_createClass(B,[{key:"destroy",value:function(){this._removeEventHandlers(),this.modal.destroy(),g(this.modalEl).remove(),this.destroySelects(),this.el.M_Datepicker=void 0}},{key:"destroySelects",value:function(){var t=this.calendarEl.querySelector(".orig-select-year");t&&M.FormSelect.getInstance(t).destroy();var e=this.calendarEl.querySelector(".orig-select-month");e&&M.FormSelect.getInstance(e).destroy()}},{key:"_insertHTMLIntoDOM",value:function(){this.options.showClearBtn&&(g(this.clearBtn).css({visibility:""}),this.clearBtn.innerHTML=this.options.i18n.clear),this.doneBtn.innerHTML=this.options.i18n.done,this.cancelBtn.innerHTML=this.options.i18n.cancel,this.options.container?this.$modalEl.appendTo(this.options.container):this.$modalEl.insertBefore(this.el)}},{key:"_setupModal",value:function(){var t=this;this.modalEl.id="modal-"+this.id,this.modal=M.Modal.init(this.modalEl,{onCloseEnd:function(){t.isOpen=!1}})}},{key:"toString",value:function(t){var e=this;return t=t||this.options.format,B._isDate(this.date)?t.split(/(d{1,4}|m{1,4}|y{4}|yy|!.)/g).map(function(t){return e.formats[t]?e.formats[t]():t}).join(""):""}},{key:"setDate",value:function(t,e){if(!t)return this.date=null,this._renderDateDisplay(),this.draw();if("string"==typeof t&&(t=new Date(Date.parse(t))),B._isDate(t)){var i=this.options.minDate,n=this.options.maxDate;B._isDate(i)&&tn.maxDate||n.disableWeekends&&B._isWeekend(y)||n.disableDayFn&&n.disableDayFn(y),isEmpty:C,isStartRange:x,isEndRange:L,isInRange:T,showDaysInNextAndPreviousMonths:n.showDaysInNextAndPreviousMonths};l.push(this.renderDay($)),7==++_&&(r.push(this.renderRow(l,n.isRTL,m)),_=0,m=!(l=[]))}return this.renderTable(n,r,i)}},{key:"renderDay",value:function(t){var e=[],i="false";if(t.isEmpty){if(!t.showDaysInNextAndPreviousMonths)return'';e.push("is-outside-current-month"),e.push("is-selection-disabled")}return t.isDisabled&&e.push("is-disabled"),t.isToday&&e.push("is-today"),t.isSelected&&(e.push("is-selected"),i="true"),t.hasEvent&&e.push("has-event"),t.isInRange&&e.push("is-inrange"),t.isStartRange&&e.push("is-startrange"),t.isEndRange&&e.push("is-endrange"),'"}},{key:"renderRow",value:function(t,e,i){return''+(e?t.reverse():t).join("")+""}},{key:"renderTable",value:function(t,e,i){return'
    '+this.renderHead(t)+this.renderBody(e)+"
    "}},{key:"renderHead",value:function(t){var e=void 0,i=[];for(e=0;e<7;e++)i.push(''+this.renderDayName(t,e,!0)+"");return""+(t.isRTL?i.reverse():i).join("")+""}},{key:"renderBody",value:function(t){return""+t.join("")+""}},{key:"renderTitle",value:function(t,e,i,n,s,o){var a,r,l=void 0,h=void 0,d=void 0,u=this.options,c=i===u.minYear,p=i===u.maxYear,v='
    ',f=!0,m=!0;for(d=[],l=0;l<12;l++)d.push('");for(a='",g.isArray(u.yearRange)?(l=u.yearRange[0],h=u.yearRange[1]+1):(l=i-u.yearRange,h=1+i+u.yearRange),d=[];l=u.minYear&&d.push('");r='";v+='',v+='
    ',u.showMonthAfterYear?v+=r+a:v+=a+r,v+="
    ",c&&(0===n||u.minMonth>=n)&&(f=!1),p&&(11===n||u.maxMonth<=n)&&(m=!1);return(v+='')+"
    "}},{key:"draw",value:function(t){if(this.isOpen||t){var e,i=this.options,n=i.minYear,s=i.maxYear,o=i.minMonth,a=i.maxMonth,r="";this._y<=n&&(this._y=n,!isNaN(o)&&this._m=s&&(this._y=s,!isNaN(a)&&this._m>a&&(this._m=a)),e="datepicker-title-"+Math.random().toString(36).replace(/[^a-z]+/g,"").substr(0,2);for(var l=0;l<1;l++)this._renderDateDisplay(),r+=this.renderTitle(this,l,this.calendars[l].year,this.calendars[l].month,this.calendars[0].year,e)+this.render(this.calendars[l].year,this.calendars[l].month,e);this.destroySelects(),this.calendarEl.innerHTML=r;var h=this.calendarEl.querySelector(".orig-select-year"),d=this.calendarEl.querySelector(".orig-select-month");M.FormSelect.init(h,{classes:"select-year",dropdownOptions:{container:document.body,constrainWidth:!1}}),M.FormSelect.init(d,{classes:"select-month",dropdownOptions:{container:document.body,constrainWidth:!1}}),h.addEventListener("change",this._handleYearChange.bind(this)),d.addEventListener("change",this._handleMonthChange.bind(this)),"function"==typeof this.options.onDraw&&this.options.onDraw(this)}}},{key:"_setupEventHandlers",value:function(){this._handleInputKeydownBound=this._handleInputKeydown.bind(this),this._handleInputClickBound=this._handleInputClick.bind(this),this._handleInputChangeBound=this._handleInputChange.bind(this),this._handleCalendarClickBound=this._handleCalendarClick.bind(this),this._finishSelectionBound=this._finishSelection.bind(this),this._handleMonthChange=this._handleMonthChange.bind(this),this._closeBound=this.close.bind(this),this.el.addEventListener("click",this._handleInputClickBound),this.el.addEventListener("keydown",this._handleInputKeydownBound),this.el.addEventListener("change",this._handleInputChangeBound),this.calendarEl.addEventListener("click",this._handleCalendarClickBound),this.doneBtn.addEventListener("click",this._finishSelectionBound),this.cancelBtn.addEventListener("click",this._closeBound),this.options.showClearBtn&&(this._handleClearClickBound=this._handleClearClick.bind(this),this.clearBtn.addEventListener("click",this._handleClearClickBound))}},{key:"_setupVariables",value:function(){var e=this;this.$modalEl=g(B._template),this.modalEl=this.$modalEl[0],this.calendarEl=this.modalEl.querySelector(".datepicker-calendar"),this.yearTextEl=this.modalEl.querySelector(".year-text"),this.dateTextEl=this.modalEl.querySelector(".date-text"),this.options.showClearBtn&&(this.clearBtn=this.modalEl.querySelector(".datepicker-clear")),this.doneBtn=this.modalEl.querySelector(".datepicker-done"),this.cancelBtn=this.modalEl.querySelector(".datepicker-cancel"),this.formats={d:function(){return e.date.getDate()},dd:function(){var t=e.date.getDate();return(t<10?"0":"")+t},ddd:function(){return e.options.i18n.weekdaysShort[e.date.getDay()]},dddd:function(){return e.options.i18n.weekdays[e.date.getDay()]},m:function(){return e.date.getMonth()+1},mm:function(){var t=e.date.getMonth()+1;return(t<10?"0":"")+t},mmm:function(){return e.options.i18n.monthsShort[e.date.getMonth()]},mmmm:function(){return e.options.i18n.months[e.date.getMonth()]},yy:function(){return(""+e.date.getFullYear()).slice(2)},yyyy:function(){return e.date.getFullYear()}}}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("click",this._handleInputClickBound),this.el.removeEventListener("keydown",this._handleInputKeydownBound),this.el.removeEventListener("change",this._handleInputChangeBound),this.calendarEl.removeEventListener("click",this._handleCalendarClickBound)}},{key:"_handleInputClick",value:function(){this.open()}},{key:"_handleInputKeydown",value:function(t){t.which===M.keys.ENTER&&(t.preventDefault(),this.open())}},{key:"_handleCalendarClick",value:function(t){if(this.isOpen){var e=g(t.target);e.hasClass("is-disabled")||(!e.hasClass("datepicker-day-button")||e.hasClass("is-empty")||e.parent().hasClass("is-disabled")?e.closest(".month-prev").length?this.prevMonth():e.closest(".month-next").length&&this.nextMonth():(this.setDate(new Date(t.target.getAttribute("data-year"),t.target.getAttribute("data-month"),t.target.getAttribute("data-day"))),this.options.autoClose&&this._finishSelection()))}}},{key:"_handleClearClick",value:function(){this.date=null,this.setInputValue(),this.close()}},{key:"_handleMonthChange",value:function(t){this.gotoMonth(t.target.value)}},{key:"_handleYearChange",value:function(t){this.gotoYear(t.target.value)}},{key:"gotoMonth",value:function(t){isNaN(t)||(this.calendars[0].month=parseInt(t,10),this.adjustCalendars())}},{key:"gotoYear",value:function(t){isNaN(t)||(this.calendars[0].year=parseInt(t,10),this.adjustCalendars())}},{key:"_handleInputChange",value:function(t){var e=void 0;t.firedBy!==this&&(e=this.options.parse?this.options.parse(this.el.value,this.options.format):new Date(Date.parse(this.el.value)),B._isDate(e)&&this.setDate(e))}},{key:"renderDayName",value:function(t,e,i){for(e+=t.firstDay;7<=e;)e-=7;return i?t.i18n.weekdaysAbbrev[e]:t.i18n.weekdays[e]}},{key:"_finishSelection",value:function(){this.setInputValue(),this.close()}},{key:"open",value:function(){if(!this.isOpen)return this.isOpen=!0,"function"==typeof this.options.onOpen&&this.options.onOpen.call(this),this.draw(),this.modal.open(),this}},{key:"close",value:function(){if(this.isOpen)return this.isOpen=!1,"function"==typeof this.options.onClose&&this.options.onClose.call(this),this.modal.close(),this}}],[{key:"init",value:function(t,e){return _get(B.__proto__||Object.getPrototypeOf(B),"init",this).call(this,this,t,e)}},{key:"_isDate",value:function(t){return/Date/.test(Object.prototype.toString.call(t))&&!isNaN(t.getTime())}},{key:"_isWeekend",value:function(t){var e=t.getDay();return 0===e||6===e}},{key:"_setToStartOfDay",value:function(t){B._isDate(t)&&t.setHours(0,0,0,0)}},{key:"_getDaysInMonth",value:function(t,e){return[31,B._isLeapYear(t)?29:28,31,30,31,30,31,31,30,31,30,31][e]}},{key:"_isLeapYear",value:function(t){return t%4==0&&t%100!=0||t%400==0}},{key:"_compareDates",value:function(t,e){return t.getTime()===e.getTime()}},{key:"_setToStartOfDay",value:function(t){B._isDate(t)&&t.setHours(0,0,0,0)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Datepicker}},{key:"defaults",get:function(){return e}}]),B}();t._template=['"].join(""),M.Datepicker=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"datepicker","M_Datepicker")}(cash),function(h){"use strict";var e={dialRadius:135,outerRadius:105,innerRadius:70,tickRadius:20,duration:350,container:null,defaultTime:"now",fromNow:0,showClearBtn:!1,i18n:{cancel:"Cancel",clear:"Clear",done:"Ok"},autoClose:!1,twelveHour:!0,vibrate:!0,onOpenStart:null,onOpenEnd:null,onCloseStart:null,onCloseEnd:null,onSelect:null},t=function(t){function f(t,e){_classCallCheck(this,f);var i=_possibleConstructorReturn(this,(f.__proto__||Object.getPrototypeOf(f)).call(this,f,t,e));return(i.el.M_Timepicker=i).options=h.extend({},f.defaults,e),i.id=M.guid(),i._insertHTMLIntoDOM(),i._setupModal(),i._setupVariables(),i._setupEventHandlers(),i._clockSetup(),i._pickerSetup(),i}return _inherits(f,Component),_createClass(f,[{key:"destroy",value:function(){this._removeEventHandlers(),this.modal.destroy(),h(this.modalEl).remove(),this.el.M_Timepicker=void 0}},{key:"_setupEventHandlers",value:function(){this._handleInputKeydownBound=this._handleInputKeydown.bind(this),this._handleInputClickBound=this._handleInputClick.bind(this),this._handleClockClickStartBound=this._handleClockClickStart.bind(this),this._handleDocumentClickMoveBound=this._handleDocumentClickMove.bind(this),this._handleDocumentClickEndBound=this._handleDocumentClickEnd.bind(this),this.el.addEventListener("click",this._handleInputClickBound),this.el.addEventListener("keydown",this._handleInputKeydownBound),this.plate.addEventListener("mousedown",this._handleClockClickStartBound),this.plate.addEventListener("touchstart",this._handleClockClickStartBound),h(this.spanHours).on("click",this.showView.bind(this,"hours")),h(this.spanMinutes).on("click",this.showView.bind(this,"minutes"))}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("click",this._handleInputClickBound),this.el.removeEventListener("keydown",this._handleInputKeydownBound)}},{key:"_handleInputClick",value:function(){this.open()}},{key:"_handleInputKeydown",value:function(t){t.which===M.keys.ENTER&&(t.preventDefault(),this.open())}},{key:"_handleClockClickStart",value:function(t){t.preventDefault();var e=this.plate.getBoundingClientRect(),i=e.left,n=e.top;this.x0=i+this.options.dialRadius,this.y0=n+this.options.dialRadius,this.moved=!1;var s=f._Pos(t);this.dx=s.x-this.x0,this.dy=s.y-this.y0,this.setHand(this.dx,this.dy,!1),document.addEventListener("mousemove",this._handleDocumentClickMoveBound),document.addEventListener("touchmove",this._handleDocumentClickMoveBound),document.addEventListener("mouseup",this._handleDocumentClickEndBound),document.addEventListener("touchend",this._handleDocumentClickEndBound)}},{key:"_handleDocumentClickMove",value:function(t){t.preventDefault();var e=f._Pos(t),i=e.x-this.x0,n=e.y-this.y0;this.moved=!0,this.setHand(i,n,!1,!0)}},{key:"_handleDocumentClickEnd",value:function(t){var e=this;t.preventDefault(),document.removeEventListener("mouseup",this._handleDocumentClickEndBound),document.removeEventListener("touchend",this._handleDocumentClickEndBound);var i=f._Pos(t),n=i.x-this.x0,s=i.y-this.y0;this.moved&&n===this.dx&&s===this.dy&&this.setHand(n,s),"hours"===this.currentView?this.showView("minutes",this.options.duration/2):this.options.autoClose&&(h(this.minutesView).addClass("timepicker-dial-out"),setTimeout(function(){e.done()},this.options.duration/2)),"function"==typeof this.options.onSelect&&this.options.onSelect.call(this,this.hours,this.minutes),document.removeEventListener("mousemove",this._handleDocumentClickMoveBound),document.removeEventListener("touchmove",this._handleDocumentClickMoveBound)}},{key:"_insertHTMLIntoDOM",value:function(){this.$modalEl=h(f._template),this.modalEl=this.$modalEl[0],this.modalEl.id="modal-"+this.id;var t=document.querySelector(this.options.container);this.options.container&&t?this.$modalEl.appendTo(t):this.$modalEl.insertBefore(this.el)}},{key:"_setupModal",value:function(){var t=this;this.modal=M.Modal.init(this.modalEl,{onOpenStart:this.options.onOpenStart,onOpenEnd:this.options.onOpenEnd,onCloseStart:this.options.onCloseStart,onCloseEnd:function(){"function"==typeof t.options.onCloseEnd&&t.options.onCloseEnd.call(t),t.isOpen=!1}})}},{key:"_setupVariables",value:function(){this.currentView="hours",this.vibrate=navigator.vibrate?"vibrate":navigator.webkitVibrate?"webkitVibrate":null,this._canvas=this.modalEl.querySelector(".timepicker-canvas"),this.plate=this.modalEl.querySelector(".timepicker-plate"),this.hoursView=this.modalEl.querySelector(".timepicker-hours"),this.minutesView=this.modalEl.querySelector(".timepicker-minutes"),this.spanHours=this.modalEl.querySelector(".timepicker-span-hours"),this.spanMinutes=this.modalEl.querySelector(".timepicker-span-minutes"),this.spanAmPm=this.modalEl.querySelector(".timepicker-span-am-pm"),this.footer=this.modalEl.querySelector(".timepicker-footer"),this.amOrPm="PM"}},{key:"_pickerSetup",value:function(){var t=h('").appendTo(this.footer).on("click",this.clear.bind(this));this.options.showClearBtn&&t.css({visibility:""});var e=h('
    ');h('").appendTo(e).on("click",this.close.bind(this)),h('").appendTo(e).on("click",this.done.bind(this)),e.appendTo(this.footer)}},{key:"_clockSetup",value:function(){this.options.twelveHour&&(this.$amBtn=h('
    AM
    '),this.$pmBtn=h('
    PM
    '),this.$amBtn.on("click",this._handleAmPmClick.bind(this)).appendTo(this.spanAmPm),this.$pmBtn.on("click",this._handleAmPmClick.bind(this)).appendTo(this.spanAmPm)),this._buildHoursView(),this._buildMinutesView(),this._buildSVGClock()}},{key:"_buildSVGClock",value:function(){var t=this.options.dialRadius,e=this.options.tickRadius,i=2*t,n=f._createSVGEl("svg");n.setAttribute("class","timepicker-svg"),n.setAttribute("width",i),n.setAttribute("height",i);var s=f._createSVGEl("g");s.setAttribute("transform","translate("+t+","+t+")");var o=f._createSVGEl("circle");o.setAttribute("class","timepicker-canvas-bearing"),o.setAttribute("cx",0),o.setAttribute("cy",0),o.setAttribute("r",4);var a=f._createSVGEl("line");a.setAttribute("x1",0),a.setAttribute("y1",0);var r=f._createSVGEl("circle");r.setAttribute("class","timepicker-canvas-bg"),r.setAttribute("r",e),s.appendChild(a),s.appendChild(r),s.appendChild(o),n.appendChild(s),this._canvas.appendChild(n),this.hand=a,this.bg=r,this.bearing=o,this.g=s}},{key:"_buildHoursView",value:function(){var t=h('
    ');if(this.options.twelveHour)for(var e=1;e<13;e+=1){var i=t.clone(),n=e/6*Math.PI,s=this.options.outerRadius;i.css({left:this.options.dialRadius+Math.sin(n)*s-this.options.tickRadius+"px",top:this.options.dialRadius-Math.cos(n)*s-this.options.tickRadius+"px"}),i.html(0===e?"00":e),this.hoursView.appendChild(i[0])}else for(var o=0;o<24;o+=1){var a=t.clone(),r=o/6*Math.PI,l=0'),e=0;e<60;e+=5){var i=t.clone(),n=e/30*Math.PI;i.css({left:this.options.dialRadius+Math.sin(n)*this.options.outerRadius-this.options.tickRadius+"px",top:this.options.dialRadius-Math.cos(n)*this.options.outerRadius-this.options.tickRadius+"px"}),i.html(f._addLeadingZero(e)),this.minutesView.appendChild(i[0])}}},{key:"_handleAmPmClick",value:function(t){var e=h(t.target);this.amOrPm=e.hasClass("am-btn")?"AM":"PM",this._updateAmPmView()}},{key:"_updateAmPmView",value:function(){this.options.twelveHour&&(this.$amBtn.toggleClass("text-primary","AM"===this.amOrPm),this.$pmBtn.toggleClass("text-primary","PM"===this.amOrPm))}},{key:"_updateTimeFromInput",value:function(){var t=((this.el.value||this.options.defaultTime||"")+"").split(":");if(this.options.twelveHour&&void 0!==t[1]&&(0','",""].join(""),M.Timepicker=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"timepicker","M_Timepicker")}(cash),function(s){"use strict";var e={},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_CharacterCounter=i).options=s.extend({},n.defaults,e),i.isInvalid=!1,i.isValidLength=!1,i._setupCounter(),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._removeEventHandlers(),this.el.CharacterCounter=void 0,this._removeCounter()}},{key:"_setupEventHandlers",value:function(){this._handleUpdateCounterBound=this.updateCounter.bind(this),this.el.addEventListener("focus",this._handleUpdateCounterBound,!0),this.el.addEventListener("input",this._handleUpdateCounterBound,!0)}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("focus",this._handleUpdateCounterBound,!0),this.el.removeEventListener("input",this._handleUpdateCounterBound,!0)}},{key:"_setupCounter",value:function(){this.counterEl=document.createElement("span"),s(this.counterEl).addClass("character-counter").css({float:"right","font-size":"12px",height:1}),this.$el.parent().append(this.counterEl)}},{key:"_removeCounter",value:function(){s(this.counterEl).remove()}},{key:"updateCounter",value:function(){var t=+this.$el.attr("data-length"),e=this.el.value.length;this.isValidLength=e<=t;var i=e;t&&(i+="/"+t,this._validateInput()),s(this.counterEl).html(i)}},{key:"_validateInput",value:function(){this.isValidLength&&this.isInvalid?(this.isInvalid=!1,this.$el.removeClass("invalid")):this.isValidLength||this.isInvalid||(this.isInvalid=!0,this.$el.removeClass("valid"),this.$el.addClass("invalid"))}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_CharacterCounter}},{key:"defaults",get:function(){return e}}]),n}();M.CharacterCounter=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"characterCounter","M_CharacterCounter")}(cash),function(b){"use strict";var e={duration:200,dist:-100,shift:0,padding:0,numVisible:5,fullWidth:!1,indicators:!1,noWrap:!1,onCycleTo:null},t=function(t){function i(t,e){_classCallCheck(this,i);var n=_possibleConstructorReturn(this,(i.__proto__||Object.getPrototypeOf(i)).call(this,i,t,e));return(n.el.M_Carousel=n).options=b.extend({},i.defaults,e),n.hasMultipleSlides=1'),n.$el.find(".carousel-item").each(function(t,e){if(n.images.push(t),n.showIndicators){var i=b('
  • ');0===e&&i[0].classList.add("active"),n.$indicators.append(i)}}),n.showIndicators&&n.$el.append(n.$indicators),n.count=n.images.length,n.options.numVisible=Math.min(n.count,n.options.numVisible),n.xform="transform",["webkit","Moz","O","ms"].every(function(t){var e=t+"Transform";return void 0===document.body.style[e]||(n.xform=e,!1)}),n._setupEventHandlers(),n._scroll(n.offset),n}return _inherits(i,Component),_createClass(i,[{key:"destroy",value:function(){this._removeEventHandlers(),this.el.M_Carousel=void 0}},{key:"_setupEventHandlers",value:function(){var i=this;this._handleCarouselTapBound=this._handleCarouselTap.bind(this),this._handleCarouselDragBound=this._handleCarouselDrag.bind(this),this._handleCarouselReleaseBound=this._handleCarouselRelease.bind(this),this._handleCarouselClickBound=this._handleCarouselClick.bind(this),void 0!==window.ontouchstart&&(this.el.addEventListener("touchstart",this._handleCarouselTapBound),this.el.addEventListener("touchmove",this._handleCarouselDragBound),this.el.addEventListener("touchend",this._handleCarouselReleaseBound)),this.el.addEventListener("mousedown",this._handleCarouselTapBound),this.el.addEventListener("mousemove",this._handleCarouselDragBound),this.el.addEventListener("mouseup",this._handleCarouselReleaseBound),this.el.addEventListener("mouseleave",this._handleCarouselReleaseBound),this.el.addEventListener("click",this._handleCarouselClickBound),this.showIndicators&&this.$indicators&&(this._handleIndicatorClickBound=this._handleIndicatorClick.bind(this),this.$indicators.find(".indicator-item").each(function(t,e){t.addEventListener("click",i._handleIndicatorClickBound)}));var t=M.throttle(this._handleResize,200);this._handleThrottledResizeBound=t.bind(this),window.addEventListener("resize",this._handleThrottledResizeBound)}},{key:"_removeEventHandlers",value:function(){var i=this;void 0!==window.ontouchstart&&(this.el.removeEventListener("touchstart",this._handleCarouselTapBound),this.el.removeEventListener("touchmove",this._handleCarouselDragBound),this.el.removeEventListener("touchend",this._handleCarouselReleaseBound)),this.el.removeEventListener("mousedown",this._handleCarouselTapBound),this.el.removeEventListener("mousemove",this._handleCarouselDragBound),this.el.removeEventListener("mouseup",this._handleCarouselReleaseBound),this.el.removeEventListener("mouseleave",this._handleCarouselReleaseBound),this.el.removeEventListener("click",this._handleCarouselClickBound),this.showIndicators&&this.$indicators&&this.$indicators.find(".indicator-item").each(function(t,e){t.removeEventListener("click",i._handleIndicatorClickBound)}),window.removeEventListener("resize",this._handleThrottledResizeBound)}},{key:"_handleCarouselTap",value:function(t){"mousedown"===t.type&&b(t.target).is("img")&&t.preventDefault(),this.pressed=!0,this.dragged=!1,this.verticalDragged=!1,this.reference=this._xpos(t),this.referenceY=this._ypos(t),this.velocity=this.amplitude=0,this.frame=this.offset,this.timestamp=Date.now(),clearInterval(this.ticker),this.ticker=setInterval(this._trackBound,100)}},{key:"_handleCarouselDrag",value:function(t){var e=void 0,i=void 0,n=void 0;if(this.pressed)if(e=this._xpos(t),i=this._ypos(t),n=this.reference-e,Math.abs(this.referenceY-i)<30&&!this.verticalDragged)(2=this.dim*(this.count-1)?this.target=this.dim*(this.count-1):this.target<0&&(this.target=0)),this.amplitude=this.target-this.offset,this.timestamp=Date.now(),requestAnimationFrame(this._autoScrollBound),this.dragged&&(t.preventDefault(),t.stopPropagation()),!1}},{key:"_handleCarouselClick",value:function(t){if(this.dragged)return t.preventDefault(),t.stopPropagation(),!1;if(!this.options.fullWidth){var e=b(t.target).closest(".carousel-item").index();0!==this._wrap(this.center)-e&&(t.preventDefault(),t.stopPropagation()),this._cycleTo(e)}}},{key:"_handleIndicatorClick",value:function(t){t.stopPropagation();var e=b(t.target).closest(".indicator-item");e.length&&this._cycleTo(e.index())}},{key:"_handleResize",value:function(t){this.options.fullWidth?(this.itemWidth=this.$el.find(".carousel-item").first().innerWidth(),this.imageHeight=this.$el.find(".carousel-item.active").height(),this.dim=2*this.itemWidth+this.options.padding,this.offset=2*this.center*this.itemWidth,this.target=this.offset,this._setCarouselHeight(!0)):this._scroll()}},{key:"_setCarouselHeight",value:function(t){var i=this,e=this.$el.find(".carousel-item.active").length?this.$el.find(".carousel-item.active").first():this.$el.find(".carousel-item").first(),n=e.find("img").first();if(n.length)if(n[0].complete){var s=n.height();if(0=this.count?t%this.count:t<0?this._wrap(this.count+t%this.count):t}},{key:"_track",value:function(){var t,e,i,n;e=(t=Date.now())-this.timestamp,this.timestamp=t,i=this.offset-this.frame,this.frame=this.offset,n=1e3*i/(1+e),this.velocity=.8*n+.2*this.velocity}},{key:"_autoScroll",value:function(){var t=void 0,e=void 0;this.amplitude&&(t=Date.now()-this.timestamp,2<(e=this.amplitude*Math.exp(-t/this.options.duration))||e<-2?(this._scroll(this.target-e),requestAnimationFrame(this._autoScrollBound)):this._scroll(this.target))}},{key:"_scroll",value:function(t){var e=this;this.$el.hasClass("scrolling")||this.el.classList.add("scrolling"),null!=this.scrollingTimeout&&window.clearTimeout(this.scrollingTimeout),this.scrollingTimeout=window.setTimeout(function(){e.$el.removeClass("scrolling")},this.options.duration);var i,n,s,o,a=void 0,r=void 0,l=void 0,h=void 0,d=void 0,u=void 0,c=this.center,p=1/this.options.numVisible;if(this.offset="number"==typeof t?t:this.offset,this.center=Math.floor((this.offset+this.dim/2)/this.dim),o=-(s=(n=this.offset-this.center*this.dim)<0?1:-1)*n*2/this.dim,i=this.count>>1,this.options.fullWidth?(l="translateX(0)",u=1):(l="translateX("+(this.el.clientWidth-this.itemWidth)/2+"px) ",l+="translateY("+(this.el.clientHeight-this.itemHeight)/2+"px)",u=1-p*o),this.showIndicators){var v=this.center%this.count,f=this.$indicators.find(".indicator-item.active");f.index()!==v&&(f.removeClass("active"),this.$indicators.find(".indicator-item").eq(v)[0].classList.add("active"))}if(!this.noWrap||0<=this.center&&this.center=this.count||e<0){if(this.noWrap)return;e=this._wrap(e)}this._cycleTo(e)}},{key:"prev",value:function(t){(void 0===t||isNaN(t))&&(t=1);var e=this.center-t;if(e>=this.count||e<0){if(this.noWrap)return;e=this._wrap(e)}this._cycleTo(e)}},{key:"set",value:function(t,e){if((void 0===t||isNaN(t))&&(t=0),t>this.count||t<0){if(this.noWrap)return;t=this._wrap(t)}this._cycleTo(t,e)}}],[{key:"init",value:function(t,e){return _get(i.__proto__||Object.getPrototypeOf(i),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Carousel}},{key:"defaults",get:function(){return e}}]),i}();M.Carousel=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"carousel","M_Carousel")}(cash),function(S){"use strict";var e={onOpen:void 0,onClose:void 0},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_TapTarget=i).options=S.extend({},n.defaults,e),i.isOpen=!1,i.$origin=S("#"+i.$el.attr("data-target")),i._setup(),i._calculatePositioning(),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._removeEventHandlers(),this.el.TapTarget=void 0}},{key:"_setupEventHandlers",value:function(){this._handleDocumentClickBound=this._handleDocumentClick.bind(this),this._handleTargetClickBound=this._handleTargetClick.bind(this),this._handleOriginClickBound=this._handleOriginClick.bind(this),this.el.addEventListener("click",this._handleTargetClickBound),this.originEl.addEventListener("click",this._handleOriginClickBound);var t=M.throttle(this._handleResize,200);this._handleThrottledResizeBound=t.bind(this),window.addEventListener("resize",this._handleThrottledResizeBound)}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("click",this._handleTargetClickBound),this.originEl.removeEventListener("click",this._handleOriginClickBound),window.removeEventListener("resize",this._handleThrottledResizeBound)}},{key:"_handleTargetClick",value:function(t){this.open()}},{key:"_handleOriginClick",value:function(t){this.close()}},{key:"_handleResize",value:function(t){this._calculatePositioning()}},{key:"_handleDocumentClick",value:function(t){S(t.target).closest(".tap-target-wrapper").length||(this.close(),t.preventDefault(),t.stopPropagation())}},{key:"_setup",value:function(){this.wrapper=this.$el.parent()[0],this.waveEl=S(this.wrapper).find(".tap-target-wave")[0],this.originEl=S(this.wrapper).find(".tap-target-origin")[0],this.contentEl=this.$el.find(".tap-target-content")[0],S(this.wrapper).hasClass(".tap-target-wrapper")||(this.wrapper=document.createElement("div"),this.wrapper.classList.add("tap-target-wrapper"),this.$el.before(S(this.wrapper)),this.wrapper.append(this.el)),this.contentEl||(this.contentEl=document.createElement("div"),this.contentEl.classList.add("tap-target-content"),this.$el.append(this.contentEl)),this.waveEl||(this.waveEl=document.createElement("div"),this.waveEl.classList.add("tap-target-wave"),this.originEl||(this.originEl=this.$origin.clone(!0,!0),this.originEl.addClass("tap-target-origin"),this.originEl.removeAttr("id"),this.originEl.removeAttr("style"),this.originEl=this.originEl[0],this.waveEl.append(this.originEl)),this.wrapper.append(this.waveEl))}},{key:"_calculatePositioning",value:function(){var t="fixed"===this.$origin.css("position");if(!t)for(var e=this.$origin.parents(),i=0;i'+t.getAttribute("label")+"")[0]),i.each(function(t){var e=n._appendOptionWithIcon(n.$el,t,"optgroup-option");n._addOptionToValueDict(t,e)})}}),this.$el.after(this.dropdownOptions),this.input=document.createElement("input"),d(this.input).addClass("select-dropdown dropdown-trigger"),this.input.setAttribute("type","text"),this.input.setAttribute("readonly","true"),this.input.setAttribute("data-target",this.dropdownOptions.id),this.el.disabled&&d(this.input).prop("disabled","true"),this.$el.before(this.input),this._setValueToInput();var t=d('');if(this.$el.before(t[0]),!this.el.disabled){var e=d.extend({},this.options.dropdownOptions);e.onOpenEnd=function(t){var e=d(n.dropdownOptions).find(".selected").first();if(e.length&&(M.keyDown=!0,n.dropdown.focusedIndex=e.index(),n.dropdown._focusFocusedItem(),M.keyDown=!1,n.dropdown.isScrollable)){var i=e[0].getBoundingClientRect().top-n.dropdownOptions.getBoundingClientRect().top;i-=n.dropdownOptions.clientHeight/2,n.dropdownOptions.scrollTop=i}},this.isMultiple&&(e.closeOnClick=!1),this.dropdown=M.Dropdown.init(this.input,e)}this._setSelectedStates()}},{key:"_addOptionToValueDict",value:function(t,e){var i=Object.keys(this._valueDict).length,n=this.dropdownOptions.id+i,s={};e.id=n,s.el=t,s.optionEl=e,this._valueDict[n]=s}},{key:"_removeDropdown",value:function(){d(this.wrapper).find(".caret").remove(),d(this.input).remove(),d(this.dropdownOptions).remove(),d(this.wrapper).before(this.$el),d(this.wrapper).remove()}},{key:"_appendOptionWithIcon",value:function(t,e,i){var n=e.disabled?"disabled ":"",s="optgroup-option"===i?"optgroup-option ":"",o=this.isMultiple?'":e.innerHTML,a=d("
  • "),r=d("");r.html(o),a.addClass(n+" "+s),a.append(r);var l=e.getAttribute("data-icon");if(l){var h=d('');a.prepend(h)}return d(this.dropdownOptions).append(a[0]),a[0]}},{key:"_toggleEntryFromArray",value:function(t){var e=!this._keysSelected.hasOwnProperty(t),i=d(this._valueDict[t].optionEl);return e?this._keysSelected[t]=!0:delete this._keysSelected[t],i.toggleClass("selected",e),i.find('input[type="checkbox"]').prop("checked",e),i.prop("selected",e),e}},{key:"_setValueToInput",value:function(){var i=[];if(this.$el.find("option").each(function(t){if(d(t).prop("selected")){var e=d(t).text();i.push(e)}}),!i.length){var t=this.$el.find("option:disabled").eq(0);t.length&&""===t[0].value&&i.push(t.text())}this.input.value=i.join(", ")}},{key:"_setSelectedStates",value:function(){for(var t in this._keysSelected={},this._valueDict){var e=this._valueDict[t],i=d(e.el).prop("selected");d(e.optionEl).find('input[type="checkbox"]').prop("checked",i),i?(this._activateOption(d(this.dropdownOptions),d(e.optionEl)),this._keysSelected[t]=!0):d(e.optionEl).removeClass("selected")}}},{key:"_activateOption",value:function(t,e){e&&(this.isMultiple||t.find("li.selected").removeClass("selected"),d(e).addClass("selected"))}},{key:"getSelectedValues",value:function(){var t=[];for(var e in this._keysSelected)t.push(this._valueDict[e].el.value);return t}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_FormSelect}},{key:"defaults",get:function(){return e}}]),n}();M.FormSelect=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"formSelect","M_FormSelect")}(cash),function(s,e){"use strict";var i={},t=function(t){function n(t,e){_classCallCheck(this,n);var i=_possibleConstructorReturn(this,(n.__proto__||Object.getPrototypeOf(n)).call(this,n,t,e));return(i.el.M_Range=i).options=s.extend({},n.defaults,e),i._mousedown=!1,i._setupThumb(),i._setupEventHandlers(),i}return _inherits(n,Component),_createClass(n,[{key:"destroy",value:function(){this._removeEventHandlers(),this._removeThumb(),this.el.M_Range=void 0}},{key:"_setupEventHandlers",value:function(){this._handleRangeChangeBound=this._handleRangeChange.bind(this),this._handleRangeMousedownTouchstartBound=this._handleRangeMousedownTouchstart.bind(this),this._handleRangeInputMousemoveTouchmoveBound=this._handleRangeInputMousemoveTouchmove.bind(this),this._handleRangeMouseupTouchendBound=this._handleRangeMouseupTouchend.bind(this),this._handleRangeBlurMouseoutTouchleaveBound=this._handleRangeBlurMouseoutTouchleave.bind(this),this.el.addEventListener("change",this._handleRangeChangeBound),this.el.addEventListener("mousedown",this._handleRangeMousedownTouchstartBound),this.el.addEventListener("touchstart",this._handleRangeMousedownTouchstartBound),this.el.addEventListener("input",this._handleRangeInputMousemoveTouchmoveBound),this.el.addEventListener("mousemove",this._handleRangeInputMousemoveTouchmoveBound),this.el.addEventListener("touchmove",this._handleRangeInputMousemoveTouchmoveBound),this.el.addEventListener("mouseup",this._handleRangeMouseupTouchendBound),this.el.addEventListener("touchend",this._handleRangeMouseupTouchendBound),this.el.addEventListener("blur",this._handleRangeBlurMouseoutTouchleaveBound),this.el.addEventListener("mouseout",this._handleRangeBlurMouseoutTouchleaveBound),this.el.addEventListener("touchleave",this._handleRangeBlurMouseoutTouchleaveBound)}},{key:"_removeEventHandlers",value:function(){this.el.removeEventListener("change",this._handleRangeChangeBound),this.el.removeEventListener("mousedown",this._handleRangeMousedownTouchstartBound),this.el.removeEventListener("touchstart",this._handleRangeMousedownTouchstartBound),this.el.removeEventListener("input",this._handleRangeInputMousemoveTouchmoveBound),this.el.removeEventListener("mousemove",this._handleRangeInputMousemoveTouchmoveBound),this.el.removeEventListener("touchmove",this._handleRangeInputMousemoveTouchmoveBound),this.el.removeEventListener("mouseup",this._handleRangeMouseupTouchendBound),this.el.removeEventListener("touchend",this._handleRangeMouseupTouchendBound),this.el.removeEventListener("blur",this._handleRangeBlurMouseoutTouchleaveBound),this.el.removeEventListener("mouseout",this._handleRangeBlurMouseoutTouchleaveBound),this.el.removeEventListener("touchleave",this._handleRangeBlurMouseoutTouchleaveBound)}},{key:"_handleRangeChange",value:function(){s(this.value).html(this.$el.val()),s(this.thumb).hasClass("active")||this._showRangeBubble();var t=this._calcRangeOffset();s(this.thumb).addClass("active").css("left",t+"px")}},{key:"_handleRangeMousedownTouchstart",value:function(t){if(s(this.value).html(this.$el.val()),this._mousedown=!0,this.$el.addClass("active"),s(this.thumb).hasClass("active")||this._showRangeBubble(),"input"!==t.type){var e=this._calcRangeOffset();s(this.thumb).addClass("active").css("left",e+"px")}}},{key:"_handleRangeInputMousemoveTouchmove",value:function(){if(this._mousedown){s(this.thumb).hasClass("active")||this._showRangeBubble();var t=this._calcRangeOffset();s(this.thumb).addClass("active").css("left",t+"px"),s(this.value).html(this.$el.val())}}},{key:"_handleRangeMouseupTouchend",value:function(){this._mousedown=!1,this.$el.removeClass("active")}},{key:"_handleRangeBlurMouseoutTouchleave",value:function(){if(!this._mousedown){var t=7+parseInt(this.$el.css("padding-left"))+"px";s(this.thumb).hasClass("active")&&(e.remove(this.thumb),e({targets:this.thumb,height:0,width:0,top:10,easing:"easeOutQuad",marginLeft:t,duration:100})),s(this.thumb).removeClass("active")}}},{key:"_setupThumb",value:function(){this.thumb=document.createElement("span"),this.value=document.createElement("span"),s(this.thumb).addClass("thumb"),s(this.value).addClass("value"),s(this.thumb).append(this.value),this.$el.after(this.thumb)}},{key:"_removeThumb",value:function(){s(this.thumb).remove()}},{key:"_showRangeBubble",value:function(){var t=-7+parseInt(s(this.thumb).parent().css("padding-left"))+"px";e.remove(this.thumb),e({targets:this.thumb,height:30,width:30,top:-30,marginLeft:t,duration:300,easing:"easeOutQuint"})}},{key:"_calcRangeOffset",value:function(){var t=this.$el.width()-15,e=parseFloat(this.$el.attr("max"))||100,i=parseFloat(this.$el.attr("min"))||0;return(parseFloat(this.$el.val())-i)/(e-i)*t}}],[{key:"init",value:function(t,e){return _get(n.__proto__||Object.getPrototypeOf(n),"init",this).call(this,this,t,e)}},{key:"getInstance",value:function(t){return(t.jquery?t[0]:t).M_Range}},{key:"defaults",get:function(){return i}}]),n}();M.Range=t,M.jQueryLoaded&&M.initializeJqueryWrapper(t,"range","M_Range"),t.init(s("input[type=range]"))}(cash,M.anime); \ No newline at end of file diff --git a/app/assets/javascripts/vendor/medium-editor.min.js b/app/assets/javascripts/vendor/medium-editor.min.js new file mode 100644 index 000000000..386781608 --- /dev/null +++ b/app/assets/javascripts/vendor/medium-editor.min.js @@ -0,0 +1,4 @@ +"classList"in document.createElement("_")||!function(a){"use strict";if("Element"in a){var b="classList",c="prototype",d=a.Element[c],e=Object,f=String[c].trim||function(){return this.replace(/^\s+|\s+$/g,"")},g=Array[c].indexOf||function(a){for(var b=0,c=this.length;c>b;b++)if(b in this&&this[b]===a)return b;return-1},h=function(a,b){this.name=a,this.code=DOMException[a],this.message=b},i=function(a,b){if(""===b)throw new h("SYNTAX_ERR","An invalid or illegal string was specified");if(/\s/.test(b))throw new h("INVALID_CHARACTER_ERR","String contains an invalid character");return g.call(a,b)},j=function(a){for(var b=f.call(a.getAttribute("class")||""),c=b?b.split(/\s+/):[],d=0,e=c.length;e>d;d++)this.push(c[d]);this._updateClassName=function(){a.setAttribute("class",this.toString())}},k=j[c]=[],l=function(){return new j(this)};if(h[c]=Error[c],k.item=function(a){return this[a]||null},k.contains=function(a){return a+="",-1!==i(this,a)},k.add=function(){var a,b=arguments,c=0,d=b.length,e=!1;do a=b[c]+"",-1===i(this,a)&&(this.push(a),e=!0);while(++ci;i++)e+=String.fromCharCode(f[i]);c.push(e)}else if("Blob"===b(a)||"File"===b(a)){if(!g)throw new h("NOT_READABLE_ERR");var k=new g;c.push(k.readAsBinaryString(a))}else a instanceof d?"base64"===a.encoding&&p?c.push(p(a.data)):"URI"===a.encoding?c.push(decodeURIComponent(a.data)):"raw"===a.encoding&&c.push(a.data):("string"!=typeof a&&(a+=""),c.push(unescape(encodeURIComponent(a))))},e.getBlob=function(a){return arguments.length||(a=null),new d(this.data.join(""),a,"raw")},e.toString=function(){return"[object BlobBuilder]"},f.slice=function(a,b,c){var e=arguments.length;return 3>e&&(c=null),new d(this.data.slice(a,e>1?b:this.data.length),c,this.encoding)},f.toString=function(){return"[object Blob]"},f.close=function(){this.size=0,delete this.data},c}(a);a.Blob=function(a,b){var d=b?b.type||"":"",e=new c;if(a)for(var f=0,g=a.length;g>f;f++)Uint8Array&&a[f]instanceof Uint8Array?e.append(a[f].buffer):e.append(a[f]);var h=e.getBlob(d);return!h.slice&&h.webkitSlice&&(h.slice=h.webkitSlice),h};var d=Object.getPrototypeOf||function(a){return a.__proto__};a.Blob.prototype=d(new a.Blob)}("undefined"!=typeof self&&self||"undefined"!=typeof window&&window||this.content||this),function(a,b){"use strict";var c="object"==typeof module&&"undefined"!=typeof process&&process&&process.versions&&process.versions.electron;c||"object"!=typeof module?"function"==typeof define&&define.amd?define(function(){return b}):a.MediumEditor=b:module.exports=b}(this,function(){"use strict";function a(a,b){return this.init(a,b)}return a.extensions={},function(b){function c(a,b){var c,d=Array.prototype.slice.call(arguments,2);b=b||{};for(var e=0;e-1,isMac:b.navigator.platform.toUpperCase().indexOf("MAC")>=0,keyCode:{BACKSPACE:8,TAB:9,ENTER:13,ESCAPE:27,SPACE:32,DELETE:46,K:75,M:77,V:86},isMetaCtrlKey:function(a){return!!(h.isMac&&a.metaKey||!h.isMac&&a.ctrlKey)},isKey:function(a,b){var c=h.getKeyCode(a);return!1===Array.isArray(b)?c===b:-1!==b.indexOf(c)},getKeyCode:function(a){var b=a.which;return null===b&&(b=null!==a.charCode?a.charCode:a.keyCode),b},blockContainerElementNames:["p","h1","h2","h3","h4","h5","h6","blockquote","pre","ul","li","ol","address","article","aside","audio","canvas","dd","dl","dt","fieldset","figcaption","figure","footer","form","header","hgroup","main","nav","noscript","output","section","video","table","thead","tbody","tfoot","tr","th","td"],emptyElementNames:["br","col","colgroup","hr","img","input","source","wbr"],extend:function(){var a=[!0].concat(Array.prototype.slice.call(arguments));return c.apply(this,a)},defaults:function(){var a=[!1].concat(Array.prototype.slice.call(arguments));return c.apply(this,a)},createLink:function(a,b,c,d){var e=a.createElement("a");return h.moveTextRangeIntoElement(b[0],b[b.length-1],e),e.setAttribute("href",c),d&&("_blank"===d&&e.setAttribute("rel","noopener noreferrer"),e.setAttribute("target",d)),e},findOrCreateMatchingTextNodes:function(a,b,c){for(var d=a.createTreeWalker(b,NodeFilter.SHOW_ALL,null,!1),e=[],f=0,g=!1,i=null,j=null;null!==(i=d.nextNode());)if(!(i.nodeType>3))if(3===i.nodeType){if(!g&&c.startc.end+1)throw new Error("PerformLinking overshot the target!");g&&e.push(j||i),f+=i.nodeValue.length,null!==j&&(f+=j.nodeValue.length,d.nextNode()),j=null}else"img"===i.tagName.toLowerCase()&&(!g&&c.start<=f&&(g=!0),g&&e.push(i));return e},splitStartNodeIfNeeded:function(a,b,c){return b!==c?a.splitText(b-c):null},splitEndNodeIfNeeded:function(a,b,c,d){var e,f;e=d+a.nodeValue.length+(b?b.nodeValue.length:0)-1,f=c-d-(b?a.nodeValue.length:0),e>=c&&d!==e&&0!==f&&(b||a).splitText(f)},splitByBlockElements:function(b){if(3!==b.nodeType&&1!==b.nodeType)return[];var c=[],d=a.util.blockContainerElementNames.join(",");if(3===b.nodeType||0===b.querySelectorAll(d).length)return[b];for(var e=0;e0)break;d=f.nextNode()}return d},findPreviousSibling:function(a){if(!a||h.isMediumEditorElement(a))return!1;for(var b=a.previousSibling;!b&&!h.isMediumEditorElement(a.parentNode);)a=a.parentNode,b=a.previousSibling;return b},isDescendant:function(a,b,c){if(!a||!b)return!1;if(a===b)return!!c;if(1!==a.nodeType)return!1;if(d||3!==b.nodeType)return a.contains(b);for(var e=b.parentNode;null!==e;){if(e===a)return!0;e=e.parentNode}return!1},isElement:function(a){return!(!a||1!==a.nodeType)},throttle:function(a,b){var c,d,e,f=50,g=null,h=0,i=function(){h=Date.now(),g=null,e=a.apply(c,d),g||(c=d=null)};return b||0===b||(b=f),function(){var f=Date.now(),j=b-(f-h);return c=this,d=arguments,0>=j||j>b?(g&&(clearTimeout(g),g=null),h=f,e=a.apply(c,d),g||(c=d=null)):g||(g=setTimeout(i,j)),e}},traverseUp:function(a,b){if(!a)return!1;do{if(1===a.nodeType){if(b(a))return a;if(h.isMediumEditorElement(a))return!1}a=a.parentNode}while(a);return!1},htmlEntities:function(a){return String(a).replace(/&/g,"&").replace(//g,">").replace(/"/g,""")},insertHTMLCommand:function(b,c){var d,e,f,g,i,j,k,l=!1,m=["insertHTML",!1,c];if(!a.util.isEdge&&b.queryCommandSupported("insertHTML"))try{return b.execCommand.apply(b,m)}catch(n){}if(d=b.getSelection(),d.rangeCount){if(e=d.getRangeAt(0),k=e.commonAncestorContainer,h.isMediumEditorElement(k)&&!k.firstChild)e.selectNode(k.appendChild(b.createTextNode("")));else if(3===k.nodeType&&0===e.startOffset&&e.endOffset===k.nodeValue.length||3!==k.nodeType&&k.innerHTML===e.toString()){for(;!h.isMediumEditorElement(k)&&k.parentNode&&1===k.parentNode.childNodes.length&&!h.isMediumEditorElement(k.parentNode);)k=k.parentNode;e.selectNode(k)}for(e.deleteContents(),f=b.createElement("div"),f.innerHTML=c,g=b.createDocumentFragment();f.firstChild;)i=f.firstChild,j=g.appendChild(i);e.insertNode(g),j&&(e=e.cloneRange(),e.setStartAfter(j),e.collapse(!0),a.selection.selectRange(b,e)),l=!0}return b.execCommand.callListeners&&b.execCommand.callListeners(m,l),l},execFormatBlock:function(b,c){var d,e=h.getTopBlockContainer(a.selection.getSelectionStart(b));if("blockquote"===c){if(e&&(d=Array.prototype.slice.call(e.childNodes),d.some(function(a){return h.isBlockContainer(a)})))return b.execCommand("outdent",!1,null);if(h.isIE)return b.execCommand("indent",!1,c)}if(e&&c===e.nodeName.toLowerCase()&&(c="p"),h.isIE&&(c="<"+c+">"),e&&"blockquote"===e.nodeName.toLowerCase()){if(h.isIE&&"

    "===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;cd?(e=e.parentNode,c-=1):(f=f.parentNode,d-=1);for(;e!==f;)e=e.parentNode,f=f.parentNode;return e},isElementAtBeginningOfBlock:function(a){for(var b,c;!h.isBlockContainer(a)&&!h.isMediumEditorElement(a);){for(c=a;c=c.previousSibling;)if(b=3===c.nodeType?c.nodeValue:c.textContent,b.length>0)return!1;a=a.parentNode}return!0},isMediumEditorElement:function(a){return a&&a.getAttribute&&!!a.getAttribute("data-medium-editor-element")},getContainerEditorElement:function(a){return h.traverseUp(a,function(a){return h.isMediumEditorElement(a)})},isBlockContainer:function(a){return a&&3!==a.nodeType&&-1!==h.blockContainerElementNames.indexOf(a.nodeName.toLowerCase())},getClosestBlockContainer:function(a){return h.traverseUp(a,function(a){return h.isBlockContainer(a)||h.isMediumEditorElement(a)})},getTopBlockContainer:function(a){var b=h.isBlockContainer(a)?a:!1;return h.traverseUp(a,function(a){return h.isBlockContainer(a)&&(b=a),!b&&h.isMediumEditorElement(a)?(b=a,!0):!1}),b},getFirstSelectableLeafNode:function(a){for(;a&&a.firstChild;)a=a.firstChild;if(a=h.traverseUp(a,function(a){return-1===h.emptyElementNames.indexOf(a.nodeName.toLowerCase())}),"table"===a.nodeName.toLowerCase()){var b=a.querySelector("th, td");b&&(a=b)}return a},getFirstTextNode:function(a){return h.warn("getFirstTextNode is deprecated and will be removed in version 6.0.0"),h._getFirstTextNode(a)},_getFirstTextNode:function(a){if(3===a.nodeType)return a;for(var b=0;b0){var e,f=d.getRangeAt(0),g=f.cloneRange();g.selectNodeContents(a),g.setEnd(f.startContainer,f.startOffset),e=g.toString().length,c={start:e,end:e+f.toString().length},this.doesRangeStartWithImages(f,b)&&(c.startsWithImage=!0);var h=this.getTrailingImageCount(a,c,f.endContainer,f.endOffset);if(h&&(c.trailingImageCount=h),0!==e){var i=this.getIndexRelativeToAdjacentEmptyBlocks(b,a,f.startContainer,f.startOffset);-1!==i&&(c.emptyBlocksIndex=i)}}return c},importSelection:function(a,b,c,d){if(a&&b){var e=c.createRange();e.setStart(b,0),e.collapse(!0);var f,g=b,h=[],i=0,j=!1,k=!1,l=0,m=!1,n=!1,o=null;for((d||a.startsWithImage||"undefined"!=typeof a.emptyBlocksIndex)&&(n=!0);!m&&g;)if(g.nodeType>3)g=h.pop();else{if(3!==g.nodeType||k){if(a.trailingImageCount&&k&&("img"===g.nodeName.toLowerCase()&&l++,l===a.trailingImageCount)){for(var p=0;g.parentNode.childNodes[p]!==g;)p++;e.setEnd(g.parentNode,p+1),m=!0}if(!m&&1===g.nodeType)for(var q=g.childNodes.length-1;q>=0;)h.push(g.childNodes[q]),q-=1}else f=i+g.length,!j&&a.start>=i&&a.start<=f&&(n||a.start=i&&a.end<=f&&(a.trailingImageCount?k=!0:(e.setEnd(g,a.end-i),m=!0)),i=f;m||(g=h.pop())}!j&&o&&(e.setStart(o,o.length),e.setEnd(o,o.length)),"undefined"!=typeof a.emptyBlocksIndex&&(e=this.importSelectionMoveCursorPastBlocks(c,b,a.emptyBlocksIndex,e)),d&&(e=this.importSelectionMoveCursorPastAnchor(a,e)),this.selectRange(c,e)}},importSelectionMoveCursorPastAnchor:function(b,c){var d=function(a){return"a"===a.nodeName.toLowerCase()};if(b.start===b.end&&3===c.startContainer.nodeType&&c.startOffset===c.startContainer.nodeValue.length&&a.util.traverseUp(c.startContainer,d)){for(var e=c.startContainer,f=c.startContainer.parentNode;null!==f&&"a"!==f.nodeName.toLowerCase();)f.childNodes[f.childNodes.length-1]!==e?f=null:(e=f,f=f.parentNode);if(null!==f&&"a"===f.nodeName.toLowerCase()){for(var g=null,h=0;null===g&&h0)break}else g===i.currentNode&&(h=i.currentNode);return h||(h=g),f.setStart(a.util.getFirstSelectableLeafNode(h),0),f},getIndexRelativeToAdjacentEmptyBlocks:function(c,d,e,f){if(e.textContent.length>0&&f>0)return-1;var g=e;if(3!==g.nodeType&&(g=e.childNodes[f]),g){if(!a.util.isElementAtBeginningOfBlock(g))return-1;var h=a.util.findPreviousSibling(g);if(!h)return-1;if(h.nodeValue)return-1}for(var i=a.util.getClosestBlockContainer(e),j=c.createTreeWalker(d,NodeFilter.SHOW_ELEMENT,b,!1),k=0;j.nextNode();){var l=""===j.currentNode.textContent;if((l||k>0)&&(k+=1),j.currentNode===i)return k;l||(k=0)}return k},doesRangeStartWithImages:function(a,b){if(0!==a.startOffset||1!==a.startContainer.nodeType)return!1;if("img"===a.startContainer.nodeName.toLowerCase())return!0;var c=a.startContainer.querySelector("img");if(!c)return!1;for(var d=b.createTreeWalker(a.startContainer,NodeFilter.SHOW_ALL,null,!1);d.nextNode();){var e=d.currentNode;if(e===c)break;if(e.nodeValue)return!1}return!0},getTrailingImageCount:function(a,b,c,d){if(0===d||1!==c.nodeType)return 0;if("img"!==c.nodeName.toLowerCase()&&!c.querySelector("img"))return 0;for(var e=c.childNodes[d-1];e.hasChildNodes();)e=e.lastChild;for(var f,g=a,h=[],i=0,j=!1,k=!1,l=!1,m=0;!l&&g;)if(g.nodeType>3)g=h.pop();else{if(3!==g.nodeType||k){if("img"===g.nodeName.toLowerCase()&&m++,g===e)l=!0;else if(1===g.nodeType)for(var n=g.childNodes.length-1;n>=0;)h.push(g.childNodes[n]),n-=1}else m=0,f=i+g.length,!j&&b.start>=i&&b.start<=f&&(j=!0),j&&b.end>=i&&b.end<=f&&(k=!0),i=f;l||(g=h.pop())}return m},selectionContainsContent:function(a){var b=a.getSelection();if(!b||b.isCollapsed||!b.rangeCount)return!1;if(""!==b.toString().trim())return!0;var c=this.getSelectedParentElement(b.getRangeAt(0));return!(!c||!("img"===c.nodeName.toLowerCase()||1===c.nodeType&&c.querySelector("img")))},selectionInContentEditableFalse:function(a){var b,c=this.findMatchingSelectionParent(function(a){var c=a&&a.getAttribute("contenteditable");return"true"===c&&(b=!0),"#text"!==a.nodeName&&"false"===c},a);return!b&&c},getSelectionHtml:function(a){var b,c,d,e="",f=a.getSelection();if(f.rangeCount){for(d=a.createElement("div"),b=0,c=f.rangeCount;c>b;b+=1)d.appendChild(f.getRangeAt(b).cloneContents());e=d.innerHTML}return e},getCaretOffsets:function(a,b){var c,d;return b||(b=window.getSelection().getRangeAt(0)),c=b.cloneRange(),d=b.cloneRange(),c.selectNodeContents(a),c.setEnd(b.endContainer,b.endOffset),d.selectNodeContents(a),d.setStart(b.endContainer,b.endOffset),{left:c.toString().length,right:d.toString().length}},rangeSelectsSingleNode:function(a){var b=a.startContainer;return b===a.endContainer&&b.hasChildNodes()&&a.endOffset===a.startOffset+1},getSelectedParentElement:function(a){return a?this.rangeSelectsSingleNode(a)&&3!==a.startContainer.childNodes[a.startOffset].nodeType?a.startContainer.childNodes[a.startOffset]:3===a.startContainer.nodeType?a.startContainer.parentNode:a.startContainer:null},getSelectedElements:function(a){var b,c,d,e=a.getSelection();if(!e.rangeCount||e.isCollapsed||!e.getRangeAt(0).commonAncestorContainer)return[];if(b=e.getRangeAt(0),3===b.commonAncestorContainer.nodeType){for(c=[],d=b.commonAncestorContainer;d.parentNode&&1===d.parentNode.childNodes.length;)c.push(d.parentNode),d=d.parentNode;return c}return[].filter.call(b.commonAncestorContainer.getElementsByTagName("*"),function(a){return"function"==typeof e.containsNode?e.containsNode(a,!0):!0})},selectNode:function(a,b){var c=b.createRange();c.selectNodeContents(a),this.selectRange(b,c)},select:function(a,b,c,d,e){var f=a.createRange();return f.setStart(b,c),d?f.setEnd(d,e):f.collapse(!0),this.selectRange(a,f),f},clearSelection:function(a,b){b?a.getSelection().collapseToStart():a.getSelection().collapseToEnd()},moveCursor:function(a,b,c){this.select(a,b,c)},getSelectionRange:function(a){var b=a.getSelection();return 0===b.rangeCount?null:b.getRangeAt(0)},selectRange:function(a,b){var c=a.getSelection();c.removeAllRanges(),c.addRange(b)},getSelectionStart:function(a){var b=a.getSelection().anchorNode,c=b&&3===b.nodeType?b.parentNode:b;return c}};a.selection=c}(),function(){function b(b,c){return b?b.some(function(b){if("function"!=typeof b.getInteractionElements)return!1;var d=b.getInteractionElements();return d?(Array.isArray(d)||(d=[d]),d.some(function(b){return a.util.isDescendant(b,c,!0)})):!1}):!1}var c=function(a){this.base=a,this.options=this.base.options,this.events=[],this.disabledEvents={},this.customEvents={},this.listeners={}};c.prototype={InputEventOnContenteditableSupported:!a.util.isIE&&!a.util.isEdge,attachDOMEvent:function(b,c,d,e){var f=this.base.options.contentWindow,g=this.base.options.ownerDocument;b=a.util.isElement(b)||[f,g].indexOf(b)>-1?[b]:b,Array.prototype.forEach.call(b,function(a){a.addEventListener(c,d,e),this.events.push([a,c,d,e])}.bind(this))},detachDOMEvent:function(b,c,d,e){var f,g,h=this.base.options.contentWindow,i=this.base.options.ownerDocument;b&&(b=a.util.isElement(b)||[h,i].indexOf(b)>-1?[b]:b,Array.prototype.forEach.call(b,function(a){f=this.indexOfListener(a,c,d,e),-1!==f&&(g=this.events.splice(f,1)[0],g[0].removeEventListener(g[1],g[2],g[3]))}.bind(this)))},indexOfListener:function(a,b,c,d){var e,f,g;for(e=0,f=this.events.length;f>e;e+=1)if(g=this.events[e],g[0]===a&&g[1]===b&&g[2]===c&&g[3]===d)return e;return-1},detachAllDOMEvents:function(){for(var a=this.events.pop();a;)a[0].removeEventListener(a[1],a[2],a[3]),a=this.events.pop()},detachAllEventsFromElement:function(a){for(var b=this.events.filter(function(b){return b&&b[0].getAttribute&&b[0].getAttribute("medium-editor-index")===a.getAttribute("medium-editor-index")}),c=0,d=b.length;d>c;c++){var e=b[c];this.detachDOMEvent(e[0],e[1],e[2],e[3])}},attachAllEventsToElement:function(a){this.listeners.editableInput&&(this.contentCache[a.getAttribute("medium-editor-index")]=a.innerHTML),this.eventsCache&&this.eventsCache.forEach(function(b){this.attachDOMEvent(a,b.name,b.handler.bind(this))},this)},enableCustomEvent:function(a){void 0!==this.disabledEvents[a]&&delete this.disabledEvents[a]},disableCustomEvent:function(a){this.disabledEvents[a]=!0},attachCustomEvent:function(a,b){this.setupListener(a),this.customEvents[a]||(this.customEvents[a]=[]),this.customEvents[a].push(b)},detachCustomEvent:function(a,b){var c=this.indexOfCustomListener(a,b);-1!==c&&this.customEvents[a].splice(c,1)},indexOfCustomListener:function(a,b){return this.customEvents[a]&&this.customEvents[a].length?this.customEvents[a].indexOf(b):-1},detachAllCustomEvents:function(){this.customEvents={}},triggerCustomEvent:function(a,b,c){this.customEvents[a]&&!this.disabledEvents[a]&&this.customEvents[a].forEach(function(a){a(b,c)})},destroy:function(){this.detachAllDOMEvents(),this.detachAllCustomEvents(),this.detachExecCommand(),this.base.elements&&this.base.elements.forEach(function(a){a.removeAttribute("data-medium-focused")})},attachToExecCommand:function(){this.execCommandListener||(this.execCommandListener=function(a){this.handleDocumentExecCommand(a)}.bind(this),this.wrapExecCommand(),this.options.ownerDocument.execCommand.listeners.push(this.execCommandListener))},detachExecCommand:function(){var a=this.options.ownerDocument;if(this.execCommandListener&&a.execCommand.listeners){var b=a.execCommand.listeners.indexOf(this.execCommandListener);-1!==b&&a.execCommand.listeners.splice(b,1),a.execCommand.listeners.length||this.unwrapExecCommand()}},wrapExecCommand:function(){var a=this.options.ownerDocument;if(!a.execCommand.listeners){var b=function(b,c){a.execCommand.listeners&&a.execCommand.listeners.forEach(function(a){a({command:b[0],value:b[2],args:b,result:c})})},c=function(){var c=a.execCommand.orig.apply(this,arguments);if(!a.execCommand.listeners)return c;var d=Array.prototype.slice.call(arguments);return b(d,c),c};c.orig=a.execCommand,c.listeners=[],c.callListeners=b,a.execCommand=c}},unwrapExecCommand:function(){var a=this.options.ownerDocument;a.execCommand.orig&&(a.execCommand=a.execCommand.orig)},setupListener:function(a){if(!this.listeners[a]){switch(a){case"externalInteraction":this.attachDOMEvent(this.options.ownerDocument.body,"mousedown",this.handleBodyMousedown.bind(this),!0),this.attachDOMEvent(this.options.ownerDocument.body,"click",this.handleBodyClick.bind(this),!0),this.attachDOMEvent(this.options.ownerDocument.body,"focus",this.handleBodyFocus.bind(this),!0);break;case"blur":this.setupListener("externalInteraction");break;case"focus":this.setupListener("externalInteraction");break;case"editableInput":this.contentCache={},this.base.elements.forEach(function(a){this.contentCache[a.getAttribute("medium-editor-index")]=a.innerHTML},this),this.InputEventOnContenteditableSupported&&this.attachToEachElement("input",this.handleInput),this.InputEventOnContenteditableSupported||(this.setupListener("editableKeypress"),this.keypressUpdateInput=!0,this.attachDOMEvent(document,"selectionchange",this.handleDocumentSelectionChange.bind(this)),this.attachToExecCommand());break;case"editableClick":this.attachToEachElement("click",this.handleClick);break;case"editableBlur":this.attachToEachElement("blur",this.handleBlur);break;case"editableKeypress":this.attachToEachElement("keypress",this.handleKeypress);break;case"editableKeyup":this.attachToEachElement("keyup",this.handleKeyup);break;case"editableKeydown":this.attachToEachElement("keydown",this.handleKeydown);break;case"editableKeydownSpace":this.setupListener("editableKeydown");break;case"editableKeydownEnter":this.setupListener("editableKeydown");break;case"editableKeydownTab":this.setupListener("editableKeydown");break;case"editableKeydownDelete":this.setupListener("editableKeydown");break;case"editableMouseover":this.attachToEachElement("mouseover",this.handleMouseover);break;case"editableDrag":this.attachToEachElement("dragover",this.handleDragging),this.attachToEachElement("dragleave",this.handleDragging);break;case"editableDrop":this.attachToEachElement("drop",this.handleDrop);break;case"editablePaste":this.attachToEachElement("paste",this.handlePaste)}this.listeners[a]=!0}},attachToEachElement:function(a,b){this.eventsCache||(this.eventsCache=[]),this.base.elements.forEach(function(c){this.attachDOMEvent(c,a,b.bind(this))},this),this.eventsCache.push({name:a,handler:b})},cleanupElement:function(a){var b=a.getAttribute("medium-editor-index");b&&(this.detachAllEventsFromElement(a),this.contentCache&&delete this.contentCache[b])},focusElement:function(a){a.focus(),this.updateFocus(a,{target:a,type:"focus"})},updateFocus:function(c,d){var e,f=this.base.getFocusedElement();f&&"click"===d.type&&this.lastMousedownTarget&&(a.util.isDescendant(f,this.lastMousedownTarget,!0)||b(this.base.extensions,this.lastMousedownTarget))&&(e=f),e||this.base.elements.some(function(b){return!e&&a.util.isDescendant(b,c,!0)&&(e=b),!!e},this);var g=!a.util.isDescendant(f,c,!0)&&!b(this.base.extensions,c);e!==f&&(f&&g&&(f.removeAttribute("data-medium-focused"),this.triggerCustomEvent("blur",d,f)),e&&(e.setAttribute("data-medium-focused",!0),this.triggerCustomEvent("focus",d,e))),g&&this.triggerCustomEvent("externalInteraction",d)},updateInput:function(a,b){if(this.contentCache){var c=a.getAttribute("medium-editor-index"),d=a.innerHTML;d!==this.contentCache[c]&&this.triggerCustomEvent("editableInput",b,a),this.contentCache[c]=d}},handleDocumentSelectionChange:function(b){if(b.currentTarget&&b.currentTarget.activeElement){var c,d=b.currentTarget.activeElement;this.base.elements.some(function(b){return a.util.isDescendant(b,d,!0)?(c=b,!0):!1},this),c&&this.updateInput(c,{target:d,currentTarget:c})}},handleDocumentExecCommand:function(){var a=this.base.getFocusedElement();a&&this.updateInput(a,{target:a,currentTarget:a})},handleBodyClick:function(a){this.updateFocus(a.target,a)},handleBodyFocus:function(a){this.updateFocus(a.target,a); +},handleBodyMousedown:function(a){this.lastMousedownTarget=a.target},handleInput:function(a){this.updateInput(a.currentTarget,a)},handleClick:function(a){this.triggerCustomEvent("editableClick",a,a.currentTarget)},handleBlur:function(a){this.triggerCustomEvent("editableBlur",a,a.currentTarget)},handleKeypress:function(a){if(this.triggerCustomEvent("editableKeypress",a,a.currentTarget),this.keypressUpdateInput){var b={target:a.target,currentTarget:a.currentTarget};setTimeout(function(){this.updateInput(b.currentTarget,b)}.bind(this),0)}},handleKeyup:function(a){this.triggerCustomEvent("editableKeyup",a,a.currentTarget)},handleMouseover:function(a){this.triggerCustomEvent("editableMouseover",a,a.currentTarget)},handleDragging:function(a){this.triggerCustomEvent("editableDrag",a,a.currentTarget)},handleDrop:function(a){this.triggerCustomEvent("editableDrop",a,a.currentTarget)},handlePaste:function(a){this.triggerCustomEvent("editablePaste",a,a.currentTarget)},handleKeydown:function(b){return this.triggerCustomEvent("editableKeydown",b,b.currentTarget),a.util.isKey(b,a.util.keyCode.SPACE)?this.triggerCustomEvent("editableKeydownSpace",b,b.currentTarget):a.util.isKey(b,a.util.keyCode.ENTER)||b.ctrlKey&&a.util.isKey(b,a.util.keyCode.M)?this.triggerCustomEvent("editableKeydownEnter",b,b.currentTarget):a.util.isKey(b,a.util.keyCode.TAB)?this.triggerCustomEvent("editableKeydownTab",b,b.currentTarget):a.util.isKey(b,[a.util.keyCode.DELETE,a.util.keyCode.BACKSPACE])?this.triggerCustomEvent("editableKeydownDelete",b,b.currentTarget):void 0}},a.Events=c}(),function(){var b=a.Extension.extend({action:void 0,aria:void 0,tagNames:void 0,style:void 0,useQueryState:void 0,contentDefault:void 0,contentFA:void 0,classList:void 0,attrs:void 0,constructor:function(c){b.isBuiltInButton(c)?a.Extension.call(this,this.defaults[c]):a.Extension.call(this,c)},init:function(){a.Extension.prototype.init.apply(this,arguments),this.button=this.createButton(),this.on(this.button,"click",this.handleClick.bind(this))},getButton:function(){return this.button},getAction:function(){return"function"==typeof this.action?this.action(this.base.options):this.action},getAria:function(){return"function"==typeof this.aria?this.aria(this.base.options):this.aria},getTagNames:function(){return"function"==typeof this.tagNames?this.tagNames(this.base.options):this.tagNames},createButton:function(){var a=this.document.createElement("button"),b=this.contentDefault,c=this.getAria(),d=this.getEditorOption("buttonLabels");return a.classList.add("medium-editor-action"),a.classList.add("medium-editor-action-"+this.name),this.classList&&this.classList.forEach(function(b){a.classList.add(b)}),a.setAttribute("data-action",this.getAction()),c&&(a.setAttribute("title",c),a.setAttribute("aria-label",c)),this.attrs&&Object.keys(this.attrs).forEach(function(b){a.setAttribute(b,this.attrs[b])},this),"fontawesome"===d&&this.contentFA&&(b=this.contentFA),a.innerHTML=b,a},handleClick:function(a){a.preventDefault(),a.stopPropagation();var b=this.getAction();b&&this.execAction(b)},isActive:function(){return this.button.classList.contains(this.getEditorOption("activeButtonClass"))},setInactive:function(){this.button.classList.remove(this.getEditorOption("activeButtonClass")),delete this.knownState},setActive:function(){this.button.classList.add(this.getEditorOption("activeButtonClass")),delete this.knownState},queryCommandState:function(){var a=null;return this.useQueryState&&(a=this.base.queryCommandState(this.getAction())),a},isAlreadyApplied:function(a){var b,c,d=!1,e=this.getTagNames();return this.knownState===!1||this.knownState===!0?this.knownState:(e&&e.length>0&&(d=-1!==e.indexOf(a.nodeName.toLowerCase())),!d&&this.style&&(b=this.style.value.split("|"),c=this.window.getComputedStyle(a,null).getPropertyValue(this.style.prop),b.forEach(function(a){this.knownState||(d=-1!==c.indexOf(a),(d||"text-decoration"!==this.style.prop)&&(this.knownState=d))},this)),d)}});b.isBuiltInButton=function(b){return"string"==typeof b&&a.extensions.button.prototype.defaults.hasOwnProperty(b)},a.extensions.button=b}(),function(){a.extensions.button.prototype.defaults={bold:{name:"bold",action:"bold",aria:"bold",tagNames:["b","strong"],style:{prop:"font-weight",value:"700|bold"},useQueryState:!0,contentDefault:"B",contentFA:''},italic:{name:"italic",action:"italic",aria:"italic",tagNames:["i","em"],style:{prop:"font-style",value:"italic"},useQueryState:!0,contentDefault:"I",contentFA:''},underline:{name:"underline",action:"underline",aria:"underline",tagNames:["u"],style:{prop:"text-decoration",value:"underline"},useQueryState:!0,contentDefault:"U",contentFA:''},strikethrough:{name:"strikethrough",action:"strikethrough",aria:"strike through",tagNames:["strike"],style:{prop:"text-decoration",value:"line-through"},useQueryState:!0,contentDefault:"A",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;b0&&null!==g;)e=c.currentNode,f=e.nodeValue,f.length>b?(g=e.splitText(f.length-b),b=0):(g=c.previousNode(),b-=f.length);return g},performLinkingWithinElement:function(b){for(var c=this.findLinkableText(b),d=!1,e=0;e1;)e.appendChild(d.childNodes[1])}});a.extensions.autoLink=h}(),function(){function b(b){var d=a.util.getContainerEditorElement(b),e=Array.prototype.slice.call(d.parentElement.querySelectorAll("."+c));e.forEach(function(a){a.classList.remove(c)})}var c="medium-editor-dragover",d=a.Extension.extend({name:"fileDragging",allowedTypes:["image"],init:function(){a.Extension.prototype.init.apply(this,arguments),this.subscribe("editableDrag",this.handleDrag.bind(this)),this.subscribe("editableDrop",this.handleDrop.bind(this))},handleDrag:function(a){a.preventDefault(),a.dataTransfer.dropEffect="copy";var d=a.target.classList?a.target:a.target.parentElement;b(d),"dragover"===a.type&&d.classList.add(c)},handleDrop:function(a){a.preventDefault(),a.stopPropagation(),this.base.selectElement(a.target);var c=this.base.exportSelection();c.start=c.end,this.base.importSelection(c),a.dataTransfer.files&&Array.prototype.slice.call(a.dataTransfer.files).forEach(function(a){this.isAllowedFile(a)&&a.type.match("image")&&this.insertImageFile(a)},this),b(a.target)},isAllowedFile:function(a){return this.allowedTypes.some(function(b){return!!a.type.match(b)})},insertImageFile:function(b){if("function"==typeof FileReader){var c=new FileReader;c.readAsDataURL(b),c.addEventListener("load",function(b){var c=this.document.createElement("img");c.src=b.target.result,a.util.insertHTMLCommand(this.document,c.outerHTML)}.bind(this))}}});a.extensions.fileDragging=d}(),function(){var b=a.Extension.extend({name:"keyboard-commands",commands:[{command:"bold",key:"B",meta:!0,shift:!1,alt:!1},{command:"italic",key:"I",meta:!0,shift:!1,alt:!1},{command:"underline",key:"U",meta:!0,shift:!1,alt:!1}],init:function(){a.Extension.prototype.init.apply(this,arguments),this.subscribe("editableKeydown",this.handleKeydown.bind(this)),this.keys={},this.commands.forEach(function(a){var b=a.key.charCodeAt(0);this.keys[b]||(this.keys[b]=[]),this.keys[b].push(a)},this)},handleKeydown:function(b){var c=a.util.getKeyCode(b);if(this.keys[c]){var d=a.util.isMetaCtrlKey(b),e=!!b.shiftKey,f=!!b.altKey;this.keys[c].forEach(function(a){a.meta!==d||a.shift!==e||a.alt!==f&&void 0!==a.alt||(b.preventDefault(),b.stopPropagation(),"function"==typeof a.command?a.command.apply(this):!1!==a.command&&this.execAction(a.command))},this)}}});a.extensions.keyboardCommands=b}(),function(){var b=a.extensions.form.extend({name:"fontname",action:"fontName",aria:"change font name",contentDefault:"±",contentFA:'',fonts:["","Arial","Verdana","Times New Roman"],init:function(){a.extensions.form.prototype.init.apply(this,arguments)},handleClick:function(a){if(a.preventDefault(),a.stopPropagation(),!this.isDisplayed()){var b=this.document.queryCommandValue("fontName")+"";this.showForm(b)}return!1},getForm:function(){return this.form||(this.form=this.createForm()),this.form},isDisplayed:function(){return"block"===this.getForm().style.display},hideForm:function(){this.getForm().style.display="none",this.getSelect().value=""},showForm:function(a){var b=this.getSelect();this.base.saveSelection(),this.hideToolbarDefaultActions(),this.getForm().style.display="block",this.setToolbarPosition(),b.value=a||"",b.focus()},destroy:function(){return this.form?(this.form.parentNode&&this.form.parentNode.removeChild(this.form),void delete this.form):!1},doFormSave:function(){this.base.restoreSelection(),this.base.checkSelection()},doFormCancel:function(){this.base.restoreSelection(),this.clearFontName(),this.base.checkSelection()},createForm:function(){var a,b=this.document,c=b.createElement("div"),d=b.createElement("select"),e=b.createElement("a"),f=b.createElement("a");c.className="medium-editor-toolbar-form",c.id="medium-editor-toolbar-form-fontname-"+this.getEditorId(),this.on(c,"click",this.handleFormClick.bind(this));for(var g=0;g
    ':"✓",c.appendChild(f),this.on(f,"click",this.handleSaveClick.bind(this),!0),e.setAttribute("href","#"),e.className="medium-editor-toobar-close",e.innerHTML="fontawesome"===this.getEditorOption("buttonLabels")?'':"×",c.appendChild(e),this.on(e,"click",this.handleCloseClick.bind(this)),c},getSelect:function(){return this.getForm().querySelector("select.medium-editor-toolbar-select")},clearFontName:function(){a.selection.getSelectedElements(this.document).forEach(function(a){"font"===a.nodeName.toLowerCase()&&a.hasAttribute("face")&&a.removeAttribute("face")})},handleFontChange:function(){var a=this.getSelect().value;""===a?this.clearFontName():this.execAction("fontName",{value:a})},handleFormClick:function(a){a.stopPropagation()},handleSaveClick:function(a){a.preventDefault(),this.doFormSave()},handleCloseClick:function(a){a.preventDefault(),this.doFormCancel()}});a.extensions.fontName=b}(),function(){var b=a.extensions.form.extend({name:"fontsize",action:"fontSize",aria:"increase/decrease font size",contentDefault:"±",contentFA:'',init:function(){a.extensions.form.prototype.init.apply(this,arguments)},handleClick:function(a){if(a.preventDefault(),a.stopPropagation(),!this.isDisplayed()){var b=this.document.queryCommandValue("fontSize")+"";this.showForm(b)}return!1},getForm:function(){return this.form||(this.form=this.createForm()),this.form},isDisplayed:function(){return"block"===this.getForm().style.display},hideForm:function(){this.getForm().style.display="none",this.getInput().value=""},showForm:function(a){var b=this.getInput();this.base.saveSelection(),this.hideToolbarDefaultActions(),this.getForm().style.display="block",this.setToolbarPosition(),b.value=a||"",b.focus()},destroy:function(){return this.form?(this.form.parentNode&&this.form.parentNode.removeChild(this.form),void delete this.form):!1},doFormSave:function(){this.base.restoreSelection(),this.base.checkSelection()},doFormCancel:function(){this.base.restoreSelection(),this.clearFontSize(),this.base.checkSelection()},createForm:function(){var a=this.document,b=a.createElement("div"),c=a.createElement("input"),d=a.createElement("a"),e=a.createElement("a");return b.className="medium-editor-toolbar-form",b.id="medium-editor-toolbar-form-fontsize-"+this.getEditorId(),this.on(b,"click",this.handleFormClick.bind(this)),c.setAttribute("type","range"),c.setAttribute("min","1"), +c.setAttribute("max","7"),c.className="medium-editor-toolbar-input",b.appendChild(c),this.on(c,"change",this.handleSliderChange.bind(this)),e.setAttribute("href","#"),e.className="medium-editor-toobar-save",e.innerHTML="fontawesome"===this.getEditorOption("buttonLabels")?'':"✓",b.appendChild(e),this.on(e,"click",this.handleSaveClick.bind(this),!0),d.setAttribute("href","#"),d.className="medium-editor-toobar-close",d.innerHTML="fontawesome"===this.getEditorOption("buttonLabels")?'':"×",b.appendChild(d),this.on(d,"click",this.handleCloseClick.bind(this)),b},getInput:function(){return this.getForm().querySelector("input.medium-editor-toolbar-input")},clearFontSize:function(){a.selection.getSelectedElements(this.document).forEach(function(a){"font"===a.nodeName.toLowerCase()&&a.hasAttribute("size")&&a.removeAttribute("size")})},handleSliderChange:function(){var a=this.getInput().value;"4"===a?this.clearFontSize():this.execAction("fontSize",{value:a})},handleFormClick:function(a){a.stopPropagation()},handleSaveClick:function(a){a.preventDefault(),this.doFormSave()},handleCloseClick:function(a){a.preventDefault(),this.doFormCancel()}});a.extensions.fontSize=b}(),function(){function b(){return[[new RegExp(/^[\s\S]*]*>\s*|\s*<\/body[^>]*>[\s\S]*$/g),""],[new RegExp(/|/g),""],[new RegExp(/
    $/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),"

    "],[new RegExp(/\n+

    /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;g1)for(f=0;f"+a.util.htmlEntities(e[f])+"

    ");else g=a.util.htmlEntities(e[0]);a.util.insertHTMLCommand(this.document,g)}},handlePasteBinPaste:function(a){if(a.defaultPrevented)return void this.removePasteBin();var b=c(a,this.window,this.document),d=b["text/html"],e=b["text/plain"],g=f;return!this.cleanPastedHTML||d?(a.preventDefault(),this.removePasteBin(),this.doPaste(d,e,g),void this.trigger("editablePaste",{currentTarget:g,target:g},g)):void setTimeout(function(){this.cleanPastedHTML&&(d=this.getPasteBinHtml()),this.removePasteBin(),this.doPaste(d,e,g),this.trigger("editablePaste",{currentTarget:g,target:g},g)}.bind(this),0)},handleKeydown:function(b,c){a.util.isKey(b,a.util.keyCode.V)&&a.util.isMetaCtrlKey(b)&&(b.stopImmediatePropagation(),this.removePasteBin(),this.createPasteBin(c))},createPasteBin:function(b){var c,h=a.selection.getSelectionRange(this.document),i=this.window.pageYOffset;f=b,h&&(c=h.getClientRects(),i+=c.length?c[0].top:void 0!==h.startContainer.getBoundingClientRect?h.startContainer.getBoundingClientRect().top:h.getBoundingClientRect().top),e=h;var j=this.document.createElement("div");j.id=this.pasteBinId="medium-editor-pastebin-"+ +Date.now(),j.setAttribute("style","border: 1px red solid; position: absolute; top: "+i+"px; width: 10px; height: 10px; overflow: hidden; opacity: 0"),j.setAttribute("contentEditable",!0),j.innerHTML=d,this.document.body.appendChild(j),this.on(j,"focus",g),this.on(j,"focusin",g),this.on(j,"focusout",g),j.focus(),a.selection.selectNode(j,this.document),this.boundHandlePaste||(this.boundHandlePaste=this.handlePasteBinPaste.bind(this)),this.on(j,"paste",this.boundHandlePaste)},removePasteBin:function(){null!==e&&(a.selection.selectRange(this.document,e),e=null),null!==f&&(f=null);var b=this.getPasteBin();b&&b&&(this.off(b,"focus",g),this.off(b,"focusin",g),this.off(b,"focusout",g),this.off(b,"paste",this.boundHandlePaste),b.parentElement.removeChild(b))},getPasteBin:function(){return this.document.getElementById(this.pasteBinId)},getPasteBinHtml:function(){var a=this.getPasteBin();if(!a)return!1;if(a.firstChild&&"mcepastebin"===a.firstChild.id)return!1;var b=a.innerHTML;return b&&b!==d?b:!1},cleanPaste:function(a){var c,d,e,f,g=/"+a.split("

    ").join("

    ")+"

    ",d=e.querySelectorAll("a,p,div,br"),c=0;c"+d.innerHTML+"
    ":e.innerHTML=d.innerHTML,d.parentNode.replaceChild(e,d);for(f=b.querySelectorAll("span"),c=0;c0&&(d[0].classList.add(this.firstButtonClass),d[d.length-1].classList.add(this.lastButtonClass)),h},destroy:function(){this.toolbar&&(this.toolbar.parentNode&&this.toolbar.parentNode.removeChild(this.toolbar),delete this.toolbar)},getInteractionElements:function(){return this.getToolbarElement()},getToolbarElement:function(){return this.toolbar||(this.toolbar=this.createToolbar()),this.toolbar},getToolbarActionsElement:function(){return this.getToolbarElement().querySelector(".medium-editor-toolbar-actions")},initThrottledMethods:function(){this.throttledPositionToolbar=a.util.throttle(function(){this.base.isActive&&this.positionToolbarIfShown()}.bind(this))},attachEventHandlers:function(){this.subscribe("blur",this.handleBlur.bind(this)),this.subscribe("focus",this.handleFocus.bind(this)),this.subscribe("editableClick",this.handleEditableClick.bind(this)),this.subscribe("editableKeyup",this.handleEditableKeyup.bind(this)),this.on(this.document.documentElement,"mouseup",this.handleDocumentMouseup.bind(this)),this["static"]&&this.sticky&&this.on(this.window,"scroll",this.handleWindowScroll.bind(this),!0),this.on(this.window,"resize",this.handleWindowResize.bind(this))},handleWindowScroll:function(){this.positionToolbarIfShown()},handleWindowResize:function(){this.throttledPositionToolbar()},handleDocumentMouseup:function(b){return b&&b.target&&a.util.isDescendant(this.getToolbarElement(),b.target)?!1:void this.checkState()},handleEditableClick:function(){setTimeout(function(){this.checkState()}.bind(this),0)},handleEditableKeyup:function(){this.checkState()},handleBlur:function(){clearTimeout(this.hideTimeout),clearTimeout(this.delayShowTimeout),this.hideTimeout=setTimeout(function(){this.hideToolbar()}.bind(this),1)},handleFocus:function(){this.checkState()},isDisplayed:function(){return this.getToolbarElement().classList.contains("medium-editor-toolbar-active")},showToolbar:function(){clearTimeout(this.hideTimeout),this.isDisplayed()||(this.getToolbarElement().classList.add("medium-editor-toolbar-active"),this.trigger("showToolbar",{},this.base.getFocusedElement()))},hideToolbar:function(){this.isDisplayed()&&(this.getToolbarElement().classList.remove("medium-editor-toolbar-active"),this.trigger("hideToolbar",{},this.base.getFocusedElement()))},isToolbarDefaultActionsDisplayed:function(){return"block"===this.getToolbarActionsElement().style.display},hideToolbarDefaultActions:function(){this.isToolbarDefaultActionsDisplayed()&&(this.getToolbarActionsElement().style.display="none")},showToolbarDefaultActions:function(){this.hideExtensionForms(),this.isToolbarDefaultActionsDisplayed()||(this.getToolbarActionsElement().style.display="block"),this.delayShowTimeout=this.base.delay(function(){this.showToolbar()}.bind(this))},hideExtensionForms:function(){this.forEachExtension(function(a){a.hasForm&&a.isDisplayed()&&a.hideForm()})},multipleBlockElementsSelected:function(){var b=/<[^\/>][^>]*><\/[^>]+>/gim,c=new RegExp("<("+a.util.blockContainerElementNames.join("|")+")[^>]*>","g"),d=a.selection.getSelectionHtml(this.document).replace(b,""),e=d.match(c);return!!e&&e.length>1},modifySelection:function(){var b=this.window.getSelection(),c=b.getRangeAt(0);if(this.standardizeSelectionStart&&c.startContainer.nodeValue&&c.startOffset===c.startContainer.nodeValue.length){var d=a.util.findAdjacentTextNodeWithContent(a.selection.getSelectionElement(this.window),c.startContainer,this.document);if(d){for(var e=0;0===d.nodeValue.substr(e,1).trim().length;)e+=1;c=a.selection.select(this.document,d,e,c.endContainer,c.endOffset)}}},checkState:function(){if(!this.base.preventSelectionUpdates){if(!this.base.getFocusedElement()||a.selection.selectionInContentEditableFalse(this.window))return this.hideToolbar();var b=a.selection.getSelectionElement(this.window);return!b||-1===this.getEditorElements().indexOf(b)||b.getAttribute("data-disable-toolbar")?this.hideToolbar():this.updateOnEmptySelection&&this["static"]?this.showAndUpdateToolbar():!a.selection.selectionContainsContent(this.document)||this.allowMultiParagraphSelection===!1&&this.multipleBlockElementsSelected()?this.hideToolbar():void this.showAndUpdateToolbar()}},showAndUpdateToolbar:function(){this.modifySelection(),this.setToolbarButtonStates(),this.trigger("positionToolbar",{},this.base.getFocusedElement()),this.showToolbarDefaultActions(),this.setToolbarPosition()},setToolbarButtonStates:function(){this.forEachExtension(function(a){"function"==typeof a.isActive&&"function"==typeof a.setInactive&&a.setInactive()}),this.checkActiveButtons()},checkActiveButtons:function(){var b,c=[],d=null,e=a.selection.getSelectionRange(this.document),f=function(a){"function"==typeof a.checkState?a.checkState(b):"function"==typeof a.isActive&&"function"==typeof a.isAlreadyApplied&&"function"==typeof a.setActive&&!a.isActive()&&a.isAlreadyApplied(b)&&a.setActive()};if(e&&(this.forEachExtension(function(a){return"function"==typeof a.queryCommandState&&(d=a.queryCommandState(),null!==d)?void(d&&"function"==typeof a.setActive&&a.setActive()):void c.push(a)}),b=a.selection.getSelectedParentElement(e),this.getEditorElements().some(function(c){return a.util.isDescendant(c,b,!0)})))for(;b&&(c.forEach(f),!a.util.isMediumEditorElement(b));)b=b.parentNode},positionToolbarIfShown:function(){this.isDisplayed()&&this.setToolbarPosition()},setToolbarPosition:function(){var a=this.base.getFocusedElement(),b=this.window.getSelection();return a?void(!this["static"]&&b.isCollapsed||(this.showToolbar(),this.relativeContainer||(this["static"]?this.positionStaticToolbar(a):this.positionToolbar(b)),this.trigger("positionedToolbar",{},this.base.getFocusedElement()))):this},positionStaticToolbar:function(a){this.getToolbarElement().style.left="0";var b,c=this.document.documentElement&&this.document.documentElement.scrollTop||this.document.body.scrollTop,d=this.window.innerWidth,e=this.getToolbarElement(),f=a.getBoundingClientRect(),g=f.top+c,h=f.left+f.width/2,i=e.offsetHeight,j=e.offsetWidth,k=j/2;switch(this.sticky?c>g+a.offsetHeight-i-this.stickyTopOffset?(e.style.top=g+a.offsetHeight-i+"px",e.classList.remove("medium-editor-sticky-toolbar")):c>g-i-this.stickyTopOffset?(e.classList.add("medium-editor-sticky-toolbar"),e.style.top=this.stickyTopOffset+"px"):(e.classList.remove("medium-editor-sticky-toolbar"),e.style.top=g-i+"px"):e.style.top=g-i+"px",this.align){case"left":b=f.left;break;case"right":b=f.right-j;break;case"center":b=h-k}0>b?b=0:b+j>d&&(b=d-Math.ceil(j)-1),e.style.left=b+"px"},positionToolbar:function(a){this.getToolbarElement().style.left="0",this.getToolbarElement().style.right="initial";var b=a.getRangeAt(0),c=b.getBoundingClientRect();(!c||0===c.height&&0===c.width&&b.startContainer===b.endContainer)&&(c=1===b.startContainer.nodeType&&b.startContainer.querySelector("img")?b.startContainer.querySelector("img").getBoundingClientRect():b.startContainer.getBoundingClientRect());var d,e,f=this.window.innerWidth,g=this.getToolbarElement(),h=g.offsetHeight,i=g.offsetWidth,j=i/2,k=50,l=this.diffLeft-j,m=this.getEditorOption("elementsContainer"),n=["absolute","fixed"].indexOf(window.getComputedStyle(m).getPropertyValue("position"))>-1,o={},p={};n?(e=m.getBoundingClientRect(),["top","left"].forEach(function(a){p[a]=c[a]-e[a]}),p.width=c.width,p.height=c.height,c=p,f=e.width,o.top=m.scrollTop):o.top=this.window.pageYOffset,d=c.left+c.width/2,o.top+=c.top-h,c.topd?(o.left=l+j,o.right="initial"):j>f-d?(o.left="auto",o.right=0):(o.left=l+d,o.right="initial"),["top","left","right"].forEach(function(a){g.style[a]=o[a]+(isNaN(o[a])?"":"px")})}});a.extensions.toolbar=b}(),function(){var b=a.Extension.extend({init:function(){a.Extension.prototype.init.apply(this,arguments),this.subscribe("editableDrag",this.handleDrag.bind(this)),this.subscribe("editableDrop",this.handleDrop.bind(this))},handleDrag:function(a){var b="medium-editor-dragover";a.preventDefault(),a.dataTransfer.dropEffect="copy","dragover"===a.type?a.target.classList.add(b):"dragleave"===a.type&&a.target.classList.remove(b)},handleDrop:function(b){var c,d="medium-editor-dragover";b.preventDefault(),b.stopPropagation(),b.dataTransfer.files&&(c=Array.prototype.slice.call(b.dataTransfer.files,0),c.some(function(b){if(b.type.match("image")){var c,d;c=new FileReader,c.readAsDataURL(b),d="medium-img-"+ +new Date,a.util.insertHTMLCommand(this.document,''),c.onload=function(){var a=this.document.getElementById(d);a&&(a.removeAttribute("id"),a.removeAttribute("class"),a.src=c.result)}.bind(this)}}.bind(this))),b.target.classList.remove(d)}});a.extensions.imageDragging=b}(),function(){function b(b){var c=a.selection.getSelectionStart(this.options.ownerDocument),d=c.textContent,e=a.selection.getCaretOffsets(c);(void 0===d[e.left-1]||""===d[e.left-1].trim()||void 0!==d[e.left]&&""===d[e.left].trim())&&b.preventDefault()}function c(b,c){if(this.options.disableReturn||c.getAttribute("data-disable-return"))b.preventDefault();else if(this.options.disableDoubleReturn||c.getAttribute("data-disable-double-return")){var d=a.selection.getSelectionStart(this.options.ownerDocument);(d&&""===d.textContent.trim()&&"li"!==d.nodeName.toLowerCase()||d.previousElementSibling&&"br"!==d.previousElementSibling.nodeName.toLowerCase()&&""===d.previousElementSibling.textContent.trim())&&b.preventDefault()}}function d(b){var c=a.selection.getSelectionStart(this.options.ownerDocument),d=c&&c.nodeName.toLowerCase();"pre"===d&&(b.preventDefault(),a.util.insertHTMLCommand(this.options.ownerDocument," ")),a.util.isListItem(c)&&(b.preventDefault(),b.shiftKey?this.options.ownerDocument.execCommand("outdent",!1,null):this.options.ownerDocument.execCommand("indent",!1,null))}function e(b){var c,d=a.selection.getSelectionStart(this.options.ownerDocument),e=d.nodeName.toLowerCase(),f=/^(\s+|)?$/i,g=/h\d/i;a.util.isKey(b,[a.util.keyCode.BACKSPACE,a.util.keyCode.ENTER])&&d.previousElementSibling&&g.test(e)&&0===a.selection.getCaretOffsets(d).left?a.util.isKey(b,a.util.keyCode.BACKSPACE)&&f.test(d.previousElementSibling.innerHTML)?(d.previousElementSibling.parentNode.removeChild(d.previousElementSibling),b.preventDefault()):!this.options.disableDoubleReturn&&a.util.isKey(b,a.util.keyCode.ENTER)&&(c=this.options.ownerDocument.createElement("p"),c.innerHTML="
    ",d.previousElementSibling.parentNode.insertBefore(c,d),b.preventDefault()):a.util.isKey(b,a.util.keyCode.DELETE)&&d.nextElementSibling&&d.previousElementSibling&&!g.test(e)&&f.test(d.innerHTML)&&g.test(d.nextElementSibling.nodeName.toLowerCase())?(a.selection.moveCursor(this.options.ownerDocument,d.nextElementSibling),d.previousElementSibling.parentNode.removeChild(d),b.preventDefault()):a.util.isKey(b,a.util.keyCode.BACKSPACE)&&"li"===e&&f.test(d.innerHTML)&&!d.previousElementSibling&&!d.parentElement.previousElementSibling&&d.nextElementSibling&&"li"===d.nextElementSibling.nodeName.toLowerCase()?(c=this.options.ownerDocument.createElement("p"),c.innerHTML="
    ",d.parentElement.parentElement.insertBefore(c,d.parentElement),a.selection.moveCursor(this.options.ownerDocument,c),d.parentElement.removeChild(d),b.preventDefault()):a.util.isKey(b,a.util.keyCode.BACKSPACE)&&a.util.getClosestTag(d,"blockquote")!==!1&&0===a.selection.getCaretOffsets(d).left?(b.preventDefault(),a.util.execFormatBlock(this.options.ownerDocument,"p")):a.util.isKey(b,a.util.keyCode.ENTER)&&a.util.getClosestTag(d,"blockquote")!==!1&&0===a.selection.getCaretOffsets(d).right?(c=this.options.ownerDocument.createElement("p"),c.innerHTML="
    ",d.parentElement.insertBefore(c,d.nextSibling),a.selection.moveCursor(this.options.ownerDocument,c),b.preventDefault()):a.util.isKey(b,a.util.keyCode.BACKSPACE)&&a.util.isMediumEditorElement(d.parentElement)&&!d.previousElementSibling&&d.nextElementSibling&&f.test(d.innerHTML)&&(b.preventDefault(),a.selection.moveCursor(this.options.ownerDocument,d.nextSibling),d.parentElement.removeChild(d))}function f(b){var c,d=a.selection.getSelectionStart(this.options.ownerDocument);d&&(a.util.isMediumEditorElement(d)&&0===d.children.length&&!a.util.isBlockContainer(d)&&this.options.ownerDocument.execCommand("formatBlock",!1,"p"),!a.util.isKey(b,a.util.keyCode.ENTER)||a.util.isListItem(d)||a.util.isBlockContainer(d)||(c=d.nodeName.toLowerCase(),"a"===c?this.options.ownerDocument.execCommand("unlink",!1,null):b.shiftKey||b.ctrlKey||this.options.ownerDocument.execCommand("formatBlock",!1,"p")))}function g(a,b){var c=b.parentNode.querySelector('textarea[medium-editor-textarea-id="'+b.getAttribute("medium-editor-textarea-id")+'"]');c&&(c.value=b.innerHTML.trim())}function h(a){a._mediumEditors||(a._mediumEditors=[null]),this.id||(this.id=a._mediumEditors.length),a._mediumEditors[this.id]=this}function i(a){a._mediumEditors&&a._mediumEditors[this.id]&&(a._mediumEditors[this.id]=null)}function j(b,c,d){var e=[];if(b||(b=[]),"string"==typeof b&&(b=c.querySelectorAll(b)),a.util.isElement(b)&&(b=[b]),d)for(var f=0;ff;f++)b.hasAttribute(e[f].nodeName)||b.setAttribute(e[f].nodeName,e[f].value);return a.form&&this.on(a.form,"reset",function(a){a.defaultPrevented||this.resetContent(this.options.ownerDocument.getElementById(d))}.bind(this)),a.classList.add("medium-editor-hidden"),a.parentNode.insertBefore(b,a),b}function v(b,d){if(!b.getAttribute("data-medium-editor-element")){"textarea"===b.nodeName.toLowerCase()&&(b=u.call(this,b),this.instanceHandleEditableInput||(this.instanceHandleEditableInput=g.bind(this),this.subscribe("editableInput",this.instanceHandleEditableInput))),this.options.disableEditing||b.getAttribute("data-disable-editing")||(b.setAttribute("contentEditable",!0),b.setAttribute("spellcheck",this.options.spellcheck)),this.instanceHandleEditableKeydownEnter||(b.getAttribute("data-disable-return")||b.getAttribute("data-disable-double-return"))&&(this.instanceHandleEditableKeydownEnter=c.bind(this),this.subscribe("editableKeydownEnter",this.instanceHandleEditableKeydownEnter)),this.options.disableReturn||b.getAttribute("data-disable-return")||this.on(b,"keyup",f.bind(this));var e=a.util.guid();b.setAttribute("data-medium-editor-element",!0),b.classList.add("medium-editor-element"),b.setAttribute("role","textbox"),b.setAttribute("aria-multiline",!0),b.setAttribute("data-medium-editor-editor-index",d),b.setAttribute("medium-editor-index",e),B[e]=b.innerHTML,this.events.attachAllEventsToElement(b)}return b}function w(){this.subscribe("editableKeydownTab",d.bind(this)),this.subscribe("editableKeydownDelete",e.bind(this)),this.subscribe("editableKeydownEnter",e.bind(this)),this.options.disableExtraSpaces&&this.subscribe("editableKeydownSpace",b.bind(this)),this.instanceHandleEditableKeydownEnter||(this.options.disableReturn||this.options.disableDoubleReturn)&&(this.instanceHandleEditableKeydownEnter=c.bind(this),this.subscribe("editableKeydownEnter",this.instanceHandleEditableKeydownEnter))}function x(){if(this.extensions=[],Object.keys(this.options.extensions).forEach(function(a){"toolbar"!==a&&this.options.extensions[a]&&this.extensions.push(m(this.options.extensions[a],a,this))},this),t.call(this)){var b=this.options.fileDragging;b||(b={},r.call(this)||(b.allowedTypes=[])),this.addBuiltInExtension("fileDragging",b)}var c={paste:!0,"anchor-preview":o.call(this),autoLink:q.call(this),keyboardCommands:s.call(this),placeholder:p.call(this)};Object.keys(c).forEach(function(a){c[a]&&this.addBuiltInExtension(a)},this);var d=this.options.extensions.toolbar;if(!d&&n.call(this)){var e=a.util.extend({},this.options.toolbar,{allowMultiParagraphSelection:this.options.allowMultiParagraphSelection});d=new a.extensions.toolbar(e)}d&&this.extensions.push(m(d,"toolbar",this))}function y(b,c){var d=[["allowMultiParagraphSelection","toolbar.allowMultiParagraphSelection"]];return c&&d.forEach(function(b){c.hasOwnProperty(b[0])&&void 0!==c[b[0]]&&a.util.deprecated(b[0],b[1],"v6.0.0")}),a.util.defaults({},c,b)}function z(b,c){var d,e,f=/^append-(.+)$/gi,g=/justify([A-Za-z]*)$/g;if(d=f.exec(b))return a.util.execFormatBlock(this.options.ownerDocument,d[1]);if("fontSize"===b)return c.size&&a.util.deprecated(".size option for fontSize command",".value","6.0.0"),e=c.value||c.size,this.options.ownerDocument.execCommand("fontSize",!1,e);if("fontName"===b)return c.name&&a.util.deprecated(".name option for fontName command",".value","6.0.0"),e=c.value||c.name,this.options.ownerDocument.execCommand("fontName",!1,e);if("createLink"===b)return this.createLink(c);if("image"===b){var h=this.options.contentWindow.getSelection().toString().trim();return this.options.ownerDocument.execCommand("insertImage",!1,h)}if("html"===b){var i=this.options.contentWindow.getSelection().toString().trim();return a.util.insertHTMLCommand(this.options.ownerDocument,i)}if(g.exec(b)){var j=this.options.ownerDocument.execCommand(b,!1,null),k=a.selection.getSelectedParentElement(a.selection.getSelectionRange(this.options.ownerDocument));return k&&A.call(this,a.util.getTopBlockContainer(k)),j}return e=c&&c.value,this.options.ownerDocument.execCommand(b,!1,e)}function A(b){if(b){var c,d=Array.prototype.slice.call(b.childNodes).filter(function(a){var b="div"===a.nodeName.toLowerCase();return b&&!c&&(c=a.style.textAlign),b});d.length&&(this.saveSelection(),d.forEach(function(b){if(b.style.textAlign===c){var d=b.lastChild;if(d){a.util.unwrap(b,this.options.ownerDocument);var e=this.options.ownerDocument.createElement("BR");d.parentNode.insertBefore(e,d.nextSibling)}}},this),b.style.textAlign=c,this.restoreSelection())}}var B={};a.prototype={init:function(a,b){return this.options=y.call(this,this.defaults,b),this.origElements=a,this.options.elementsContainer||(this.options.elementsContainer=this.options.ownerDocument.body),this.setup()},setup:function(){this.isActive||(h.call(this,this.options.contentWindow),this.events=new a.Events(this),this.elements=[],this.addElements(this.origElements),0!==this.elements.length&&(this.isActive=!0,x.call(this),w.call(this))); +},destroy:function(){this.isActive&&(this.isActive=!1,this.extensions.forEach(function(a){"function"==typeof a.destroy&&a.destroy()},this),this.events.destroy(),this.elements.forEach(function(a){this.options.spellcheck&&(a.innerHTML=a.innerHTML),a.removeAttribute("contentEditable"),a.removeAttribute("spellcheck"),a.removeAttribute("data-medium-editor-element"),a.classList.remove("medium-editor-element"),a.removeAttribute("role"),a.removeAttribute("aria-multiline"),a.removeAttribute("medium-editor-index"),a.removeAttribute("data-medium-editor-editor-index"),a.getAttribute("medium-editor-textarea-id")&&k(a)},this),this.elements=[],this.instanceHandleEditableKeydownEnter=null,this.instanceHandleEditableInput=null,i.call(this,this.options.contentWindow))},on:function(a,b,c,d){return this.events.attachDOMEvent(a,b,c,d),this},off:function(a,b,c,d){return this.events.detachDOMEvent(a,b,c,d),this},subscribe:function(a,b){return this.events.attachCustomEvent(a,b),this},unsubscribe:function(a,b){return this.events.detachCustomEvent(a,b),this},trigger:function(a,b,c){return this.events.triggerCustomEvent(a,b,c),this},delay:function(a){var b=this;return setTimeout(function(){b.isActive&&a()},this.options.delay)},serialize:function(){var a,b,c={},d=this.elements.length;for(a=0;d>a;a+=1)b=""!==this.elements[a].id?this.elements[a].id:"element-"+a,c[b]={value:this.elements[a].innerHTML.trim()};return c},getExtensionByName:function(a){var b;return this.extensions&&this.extensions.length&&this.extensions.some(function(c){return c.name===a?(b=c,!0):!1}),b},addBuiltInExtension:function(b,c){var d,e=this.getExtensionByName(b);if(e)return e;switch(b){case"anchor":d=a.util.extend({},this.options.anchor,c),e=new a.extensions.anchor(d);break;case"anchor-preview":e=new a.extensions.anchorPreview(this.options.anchorPreview);break;case"autoLink":e=new a.extensions.autoLink;break;case"fileDragging":e=new a.extensions.fileDragging(c);break;case"fontname":e=new a.extensions.fontName(this.options.fontName);break;case"fontsize":e=new a.extensions.fontSize(c);break;case"keyboardCommands":e=new a.extensions.keyboardCommands(this.options.keyboardCommands);break;case"paste":e=new a.extensions.paste(this.options.paste);break;case"placeholder":e=new a.extensions.placeholder(this.options.placeholder);break;default:a.extensions.button.isBuiltInButton(b)&&(c?(d=a.util.defaults({},c,a.extensions.button.prototype.defaults[b]),e=new a.extensions.button(d)):e=new a.extensions.button(b))}return e&&this.extensions.push(m(e,b,this)),e},stopSelectionUpdates:function(){this.preventSelectionUpdates=!0},startSelectionUpdates:function(){this.preventSelectionUpdates=!1},checkSelection:function(){var a=this.getExtensionByName("toolbar");return a&&a.checkState(),this},queryCommandState:function(a){var b,c=/^full-(.+)$/gi,d=null;b=c.exec(a),b&&(a=b[1]);try{d=this.options.ownerDocument.queryCommandState(a)}catch(e){d=null}return d},execAction:function(b,c){var d,e,f=/^full-(.+)$/gi;return d=f.exec(b),d?(this.saveSelection(),this.selectAllContents(),e=z.call(this,d[1],c),this.restoreSelection()):e=z.call(this,b,c),"insertunorderedlist"!==b&&"insertorderedlist"!==b||a.util.cleanListDOM(this.options.ownerDocument,this.getSelectedParentElement()),this.checkSelection(),e},getSelectedParentElement:function(b){return void 0===b&&(b=this.options.contentWindow.getSelection().getRangeAt(0)),a.selection.getSelectedParentElement(b)},selectAllContents:function(){var b=a.selection.getSelectionElement(this.options.contentWindow);if(b){for(;1===b.children.length;)b=b.children[0];this.selectElement(b)}},selectElement:function(b){a.selection.selectNode(b,this.options.ownerDocument);var c=a.selection.getSelectionElement(this.options.contentWindow);c&&this.events.focusElement(c)},getFocusedElement:function(){var a;return this.elements.some(function(b){return!a&&b.getAttribute("data-medium-focused")&&(a=b),!!a},this),a},exportSelection:function(){var b=a.selection.getSelectionElement(this.options.contentWindow),c=this.elements.indexOf(b),d=null;return c>=0&&(d=a.selection.exportSelection(b,this.options.ownerDocument)),null!==d&&0!==c&&(d.editableElementIndex=c),d},saveSelection:function(){this.selectionState=this.exportSelection()},importSelection:function(b,c){if(b){var d=this.elements[b.editableElementIndex||0];a.selection.importSelection(b,d,this.options.ownerDocument,c)}},restoreSelection:function(){this.importSelection(this.selectionState)},createLink:function(b){var c,d=a.selection.getSelectionElement(this.options.contentWindow),e={};if(-1!==this.elements.indexOf(d)){try{if(this.events.disableCustomEvent("editableInput"),b.url&&a.util.deprecated(".url option for createLink",".value","6.0.0"),c=b.url||b.value,c&&c.trim().length>0){var f=this.options.contentWindow.getSelection();if(f){var g,h,i,j,k=f.getRangeAt(0),l=k.commonAncestorContainer;if(3===k.endContainer.nodeType&&3!==k.startContainer.nodeType&&0===k.startOffset&&k.startContainer.firstChild===k.endContainer&&(l=k.endContainer),h=a.util.getClosestBlockContainer(k.startContainer),i=a.util.getClosestBlockContainer(k.endContainer),3!==l.nodeType&&0!==l.textContent.length&&h===i){var m=h||d,n=this.options.ownerDocument.createDocumentFragment();this.execAction("unlink"),g=this.exportSelection(),n.appendChild(m.cloneNode(!0)),d===m?a.selection.select(this.options.ownerDocument,m.firstChild,0,m.lastChild,3===m.lastChild.nodeType?m.lastChild.nodeValue.length:m.lastChild.childNodes.length):a.selection.select(this.options.ownerDocument,m,0,m,m.childNodes.length);var o=this.exportSelection();j=a.util.findOrCreateMatchingTextNodes(this.options.ownerDocument,n,{start:g.start-o.start,end:g.end-o.start,editableElementIndex:g.editableElementIndex}),0===j.length&&(n=this.options.ownerDocument.createDocumentFragment(),n.appendChild(l.cloneNode(!0)),j=[n.firstChild.firstChild,n.firstChild.lastChild]),a.util.createLink(this.options.ownerDocument,j,c.trim());var p=(n.firstChild.innerHTML.match(/^\s+/)||[""])[0].length;a.util.insertHTMLCommand(this.options.ownerDocument,n.firstChild.innerHTML.replace(/^\s+/,"")),g.start-=p,g.end-=p,this.importSelection(g)}else this.options.ownerDocument.execCommand("createLink",!1,c);this.options.targetBlank||"_blank"===b.target?a.util.setTargetBlank(a.selection.getSelectionStart(this.options.ownerDocument),c):a.util.removeTargetBlank(a.selection.getSelectionStart(this.options.ownerDocument),c),b.buttonClass&&a.util.addClassToAnchors(a.selection.getSelectionStart(this.options.ownerDocument),b.buttonClass)}}if(this.options.targetBlank||"_blank"===b.target||b.buttonClass){e=this.options.ownerDocument.createEvent("HTMLEvents"),e.initEvent("input",!0,!0,this.options.contentWindow);for(var q=0,r=this.elements.length;r>q;q+=1)this.elements[q].dispatchEvent(e)}}finally{this.events.enableCustomEvent("editableInput")}this.events.triggerCustomEvent("editableInput",e,d)}},cleanPaste:function(a){this.getExtensionByName("paste").cleanPaste(a)},pasteHTML:function(a,b){this.getExtensionByName("paste").pasteHTML(a,b)},setContent:function(a,b){if(b=b||0,this.elements[b]){var c=this.elements[b];c.innerHTML=a,this.checkContentChanged(c)}},getContent:function(a){return a=a||0,this.elements[a]?this.elements[a].innerHTML.trim():null},checkContentChanged:function(b){b=b||a.selection.getSelectionElement(this.options.contentWindow),this.events.updateInput(b,{target:b,currentTarget:b})},resetContent:function(a){if(a){var b=this.elements.indexOf(a);return void(-1!==b&&this.setContent(B[a.getAttribute("medium-editor-index")],b))}this.elements.forEach(function(a,b){this.setContent(B[a.getAttribute("medium-editor-index")],b)},this)},addElements:function(a){var b=j(a,this.options.ownerDocument,!0);return 0===b.length?!1:void b.forEach(function(a){a=v.call(this,a,this.id),this.elements.push(a),this.trigger("addElement",{target:a,currentTarget:a},a)},this)},removeElements:function(a){var b=j(a,this.options.ownerDocument),c=b.map(function(a){return a.getAttribute("medium-editor-textarea-id")&&a.parentNode?a.parentNode.querySelector('div[medium-editor-textarea-id="'+a.getAttribute("medium-editor-textarea-id")+'"]'):a});this.elements=this.elements.filter(function(a){return-1!==c.indexOf(a)?(this.events.cleanupElement(a),a.getAttribute("medium-editor-textarea-id")&&k(a),this.trigger("removeElement",{target:a,currentTarget:a},a),!1):!0},this)}},a.getEditorFromElement=function(a){var b=a.getAttribute("data-medium-editor-editor-index"),c=a&&a.ownerDocument&&(a.ownerDocument.defaultView||a.ownerDocument.parentWindow);return c&&c._mediumEditors&&c._mediumEditors[b]?c._mediumEditors[b]:null}}(),function(){a.prototype.defaults={activeButtonClass:"medium-editor-button-active",buttonLabels:!1,delay:0,disableReturn:!1,disableDoubleReturn:!1,disableExtraSpaces:!1,disableEditing:!1,autoLink:!1,elementsContainer:!1,contentWindow:window,ownerDocument:document,targetBlank:!1,extensions:{},spellcheck:!0}}(),a.parseVersionString=function(a){var b=a.split("-"),c=b[0].split("."),d=b.length>1?b[1]:"";return{major:parseInt(c[0],10),minor:parseInt(c[1],10),revision:parseInt(c[2],10),preRelease:d,toString:function(){return[c[0],c[1],c[2]].join(".")+(d?"-"+d:"")}}},a.version=a.parseVersionString.call(this,{version:"5.23.3"}.version),a}()); \ No newline at end of file diff --git a/app/assets/javascripts/z_select.js b/app/assets/javascripts/z_select.js deleted file mode 100644 index fdbd17865..000000000 --- a/app/assets/javascripts/z_select.js +++ /dev/null @@ -1,450 +0,0 @@ -(function($) { - 'use strict'; - - let _defaults = { - classes: '', - dropdownOptions: {} - }; - - /** - * @class - * - */ - class FormSelect extends Component { - /** - * Construct FormSelect instance - * @constructor - * @param {Element} el - * @param {Object} options - */ - constructor(el, options) { - super(FormSelect, el, options); - - // Don't init if browser default version - if (this.$el.hasClass('browser-default')) { - return; - } - - this.el.M_FormSelect = this; - - /** - * Options for the select - * @member FormSelect#options - */ - this.options = $.extend({}, FormSelect.defaults, options); - - this.isMultiple = this.$el.prop('multiple'); - - // Setup - this.el.tabIndex = -1; - this._keysSelected = {}; - this._valueDict = {}; // Maps key to original and generated option element. - this._setupDropdown(); - - this._setupEventHandlers(); - } - - static get defaults() { - return _defaults; - } - - static init(els, options) { - return super.init(this, els, options); - } - - /** - * Get Instance - */ - static getInstance(el) { - let domElem = !!el.jquery ? el[0] : el; - return domElem.M_FormSelect; - } - - /** - * Teardown component - */ - destroy() { - this._removeEventHandlers(); - this._removeDropdown(); - this.el.M_FormSelect = undefined; - } - - /** - * Setup Event Handlers - */ - _setupEventHandlers() { - this._handleSelectChangeBound = this._handleSelectChange.bind(this); - this._handleOptionClickBound = this._handleOptionClick.bind(this); - this._handleInputClickBound = this._handleInputClick.bind(this); - - $(this.dropdownOptions) - .find('li:not(.optgroup)') - .each((el) => { - el.addEventListener('click', this._handleOptionClickBound); - }); - this.el.addEventListener('change', this._handleSelectChangeBound); - this.input.addEventListener('click', this._handleInputClickBound); - } - - /** - * Remove Event Handlers - */ - _removeEventHandlers() { - $(this.dropdownOptions) - .find('li:not(.optgroup)') - .each((el) => { - el.removeEventListener('click', this._handleOptionClickBound); - }); - this.el.removeEventListener('change', this._handleSelectChangeBound); - this.input.removeEventListener('click', this._handleInputClickBound); - } - - /** - * Handle Select Change - * @param {Event} e - */ - _handleSelectChange(e) { - this._setValueToInput(); - } - - /** - * Handle Option Click - * @param {Event} e - */ - _handleOptionClick(e) { - e.preventDefault(); - let optionEl = $(e.target).closest('li')[0]; - this._selectOption(optionEl); - e.stopPropagation(); - } - - _selectOption(optionEl) { - let key = optionEl.id; - if (!$(optionEl).hasClass('disabled') && !$(optionEl).hasClass('optgroup') && key.length) { - let selected = true; - - if (this.isMultiple) { - // Deselect placeholder option if still selected. - let placeholderOption = $(this.dropdownOptions).find('li.disabled.selected'); - if (placeholderOption.length) { - placeholderOption.removeClass('selected'); - placeholderOption.find('input[type="checkbox"]').prop('checked', false); - this._toggleEntryFromArray(placeholderOption[0].id); - } - selected = this._toggleEntryFromArray(key); - } else { - $(this.dropdownOptions) - .find('li') - .removeClass('selected'); - $(optionEl).toggleClass('selected', selected); - this._keysSelected = {}; - this._keysSelected[optionEl.id] = true; - } - - // Set selected on original select option - // Only trigger if selected state changed - let prevSelected = $(this._valueDict[key].el).prop('selected'); - if (prevSelected !== selected) { - $(this._valueDict[key].el).prop('selected', selected); - this.$el.trigger('change'); - } - } - - if (!this.isMultiple) { - this.dropdown.close(); - } - } - - /** - * Handle Input Click - */ - _handleInputClick() { - if (this.dropdown && this.dropdown.isOpen) { - this._setValueToInput(); - this._setSelectedStates(); - } - } - - /** - * Setup dropdown - */ - _setupDropdown() { - this.wrapper = document.createElement('div'); - $(this.wrapper).addClass('select-wrapper ' + this.options.classes); - this.$el.before($(this.wrapper)); - // Move actual select element into overflow hidden wrapper - let $hideSelect = $('
    '); - $(this.wrapper).append($hideSelect); - $hideSelect[0].appendChild(this.el); - - if (this.el.disabled) { - this.wrapper.classList.add('disabled'); - } - - // Create dropdown - this.$selectOptions = this.$el.children('option, optgroup'); - this.dropdownOptions = document.createElement('ul'); - this.dropdownOptions.id = `select-options-${M.guid()}`; - $(this.dropdownOptions).addClass( - 'dropdown-content select-dropdown ' + (this.isMultiple ? 'multiple-select-dropdown' : '') - ); - - // Create dropdown structure. - if (this.$selectOptions.length) { - this.$selectOptions.each((el) => { - if ($(el).is('option')) { - // Direct descendant option. - let optionEl; - if (this.isMultiple) { - optionEl = this._appendOptionWithIcon(this.$el, el, 'multiple'); - } else { - optionEl = this._appendOptionWithIcon(this.$el, el); - } - - this._addOptionToValueDict(el, optionEl); - } else if ($(el).is('optgroup')) { - // Optgroup. - let selectOptions = $(el).children('option'); - $(this.dropdownOptions).append( - $('
  • ' + el.getAttribute('label') + '
  • ')[0] - ); - - selectOptions.each((el) => { - let optionEl = this._appendOptionWithIcon(this.$el, el, 'optgroup-option'); - this._addOptionToValueDict(el, optionEl); - }); - } - }); - } - - $(this.wrapper).append(this.dropdownOptions); - - // Add input dropdown - this.input = document.createElement('input'); - $(this.input).addClass('select-dropdown dropdown-trigger'); - this.input.setAttribute('type', 'text'); - this.input.setAttribute('readonly', 'true'); - this.input.setAttribute('data-target', this.dropdownOptions.id); - if (this.el.disabled) { - $(this.input).prop('disabled', 'true'); - } - - $(this.wrapper).prepend(this.input); - this._setValueToInput(); - - // Add caret - let dropdownIcon = $( - '' - ); - $(this.wrapper).prepend(dropdownIcon[0]); - - // Initialize dropdown - if (!this.el.disabled) { - let dropdownOptions = $.extend({}, this.options.dropdownOptions); - let userOnOpenEnd = dropdownOptions.onOpenEnd; - - // Add callback for centering selected option when dropdown content is scrollable - dropdownOptions.onOpenEnd = (el) => { - let selectedOption = $(this.dropdownOptions) - .find('.selected') - .first(); - - if (selectedOption.length) { - // Focus selected option in dropdown - M.keyDown = true; - this.dropdown.focusedIndex = selectedOption.index(); - this.dropdown._focusFocusedItem(); - M.keyDown = false; - - // Handle scrolling to selected option - if (this.dropdown.isScrollable) { - let scrollOffset = - selectedOption[0].getBoundingClientRect().top - - this.dropdownOptions.getBoundingClientRect().top; // scroll to selected option - scrollOffset -= this.dropdownOptions.clientHeight / 2; // center in dropdown - this.dropdownOptions.scrollTop = scrollOffset; - } - } - - // Handle user declared onOpenEnd if needed - if (userOnOpenEnd && typeof userOnOpenEnd === 'function') { - userOnOpenEnd.call(this.dropdown, this.el); - } - }; - - // Prevent dropdown from closeing too early - dropdownOptions.closeOnClick = false; - - this.dropdown = M.Dropdown.init(this.input, dropdownOptions); - } - - // Add initial selections - this._setSelectedStates(); - } - - /** - * Add option to value dict - * @param {Element} el original option element - * @param {Element} optionEl generated option element - */ - _addOptionToValueDict(el, optionEl) { - let index = Object.keys(this._valueDict).length; - let key = this.dropdownOptions.id + index; - let obj = {}; - optionEl.id = key; - - obj.el = el; - obj.optionEl = optionEl; - this._valueDict[key] = obj; - } - - /** - * Remove dropdown - */ - _removeDropdown() { - $(this.wrapper) - .find('.caret') - .remove(); - $(this.input).remove(); - $(this.dropdownOptions).remove(); - $(this.wrapper).before(this.$el); - $(this.wrapper).remove(); - } - - /** - * Setup dropdown - * @param {Element} select select element - * @param {Element} option option element from select - * @param {String} type - * @return {Element} option element added - */ - _appendOptionWithIcon(select, option, type) { - // Add disabled attr if disabled - let disabledClass = option.disabled ? 'disabled ' : ''; - let optgroupClass = type === 'optgroup-option' ? 'optgroup-option ' : ''; - let multipleCheckbox = this.isMultiple - ? `` - : option.innerHTML; - let liEl = $('
  • '); - let spanEl = $(''); - spanEl.html(multipleCheckbox); - liEl.addClass(`${disabledClass} ${optgroupClass}`); - liEl.append(spanEl); - - // add icons - let iconUrl = option.getAttribute('data-icon'); - if (!!iconUrl) { - let imgEl = $(``); - liEl.prepend(imgEl); - } - - // Check for multiple type. - $(this.dropdownOptions).append(liEl[0]); - return liEl[0]; - } - - /** - * Toggle entry from option - * @param {String} key Option key - * @return {Boolean} if entry was added or removed - */ - _toggleEntryFromArray(key) { - let notAdded = !this._keysSelected.hasOwnProperty(key); - let $optionLi = $(this._valueDict[key].optionEl); - - if (notAdded) { - this._keysSelected[key] = true; - } else { - delete this._keysSelected[key]; - } - - $optionLi.toggleClass('selected', notAdded); - - // Set checkbox checked value - $optionLi.find('input[type="checkbox"]').prop('checked', notAdded); - - // use notAdded instead of true (to detect if the option is selected or not) - $optionLi.prop('selected', notAdded); - - return notAdded; - } - - /** - * Set text value to input - */ - _setValueToInput() { - let values = []; - let options = this.$el.find('option'); - - options.each((el) => { - if ($(el).prop('selected')) { - let text = $(el).text(); - values.push(text); - } - }); - - if (!values.length) { - let firstDisabled = this.$el.find('option:disabled').eq(0); - if (firstDisabled.length && firstDisabled[0].value === '') { - values.push(firstDisabled.text()); - } - } - - this.input.value = values.join(', '); - } - - /** - * Set selected state of dropdown to match actual select element - */ - _setSelectedStates() { - this._keysSelected = {}; - - for (let key in this._valueDict) { - let option = this._valueDict[key]; - let optionIsSelected = $(option.el).prop('selected'); - $(option.optionEl) - .find('input[type="checkbox"]') - .prop('checked', optionIsSelected); - if (optionIsSelected) { - this._activateOption($(this.dropdownOptions), $(option.optionEl)); - this._keysSelected[key] = true; - } else { - $(option.optionEl).removeClass('selected'); - } - } - } - - /** - * Make option as selected and scroll to selected position - * @param {jQuery} collection Select options jQuery element - * @param {Element} newOption element of the new option - */ - _activateOption(collection, newOption) { - if (newOption) { - if (!this.isMultiple) { - collection.find('li.selected').removeClass('selected'); - } - let option = $(newOption); - option.addClass('selected'); - } - } - - /** - * Get Selected Values - * @return {Array} Array of selected values - */ - getSelectedValues() { - let selectedValues = []; - for (let key in this._keysSelected) { - selectedValues.push(this._valueDict[key].el.value); - } - return selectedValues; - } - } - - M.FormSelect = FormSelect; - - if (M.jQueryLoaded) { - M.initializeJqueryWrapper(FormSelect, 'formSelect', 'M_FormSelect'); - } -})(cash); \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d3ac13265..8cc67fd85 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -11,6 +11,74 @@ *= require_self *= require material_icons *= require font-awesome - *= require tribute *= require_tree . */ + +/* Alpine.js x-cloak - hide elements until Alpine loads */ +[x-cloak] { + display: none !important; +} + +/* Instant CSS Tooltips - Reusable across the site */ +/* Usage: Add class="tooltip-left" (or tooltip-right/top/bottom) and data-tooltip="Your text here" to any element */ +.tooltip-left, +.tooltip-right, +.tooltip-top, +.tooltip-bottom { + position: relative; +} + +.tooltip-left::after, +.tooltip-right::after, +.tooltip-top::after, +.tooltip-bottom::after { + content: attr(data-tooltip); + position: absolute; + padding: 8px 12px; + background-color: rgba(0, 0, 0, 0.9); + color: white; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + border-radius: 6px; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-in-out; + z-index: 1000; +} + +/* Left tooltip - appears to the left of the element */ +.tooltip-left::after { + top: 50%; + right: calc(100% + 8px); + transform: translateY(-50%); +} + +/* Right tooltip - appears to the right of the element */ +.tooltip-right::after { + top: 50%; + left: calc(100% + 8px); + transform: translateY(-50%); +} + +/* Top tooltip - appears above the element */ +.tooltip-top::after { + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); +} + +/* Bottom tooltip - appears below the element */ +.tooltip-bottom::after { + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); +} + +/* Show tooltip on hover */ +.tooltip-left:hover::after, +.tooltip-right:hover::after, +.tooltip-top:hover::after, +.tooltip-bottom:hover::after { + opacity: 1; +} diff --git a/app/assets/stylesheets/currently-online.scss b/app/assets/stylesheets/currently-online.scss new file mode 100644 index 000000000..d2594d3e1 --- /dev/null +++ b/app/assets/stylesheets/currently-online.scss @@ -0,0 +1,217 @@ +// Currently Online Widget Styling +// A fixed position tab in bottom-right that expands to show online users + +.thredded--currently-online { + position: fixed; + bottom: 0; + right: 20px; + z-index: 1000; + background: white; + border: 1px solid #e5e7eb; + border-bottom: none; + border-radius: 8px 8px 0 0; + box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; + min-width: 250px; + max-width: 350px; + + // Collapsed state (default) + &[x-data] { + // This will be controlled by Alpine.js + } + + // Header (always visible) + header { + cursor: pointer; + padding: 12px 16px; + background: #3b82f6; // bg-notebook-blue + border-radius: 7px 7px 0 0; + user-select: none; + + &:hover { + background: #2563eb; // Slightly darker on hover + } + } + + .thredded--currently-online--title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: white; + display: flex; + align-items: center; + justify-content: space-between; + + // Add count badge + &::after { + content: attr(data-count); + background: rgba(255, 255, 255, 0.2); + padding: 2px 8px; + border-radius: 12px; + font-size: 12px; + margin-left: 8px; + } + + // Expand/collapse indicator + &::before { + content: '▼'; + font-size: 10px; + margin-right: 8px; + transition: transform 0.3s ease; + display: inline-block; + } + + &.collapsed::before { + transform: rotate(-90deg); + } + } + + // User list container + .thredded--currently-online--users { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease, padding 0.3s ease; + margin: 0; + padding: 0; + list-style: none; + background: white; + border-top: 1px solid #f3f4f6; + + &.expanded { + max-height: 400px; + overflow-y: auto; + padding: 8px 0; + } + } + + // Individual user item + .thredded--currently-online--user { + padding: 0; + margin: 0; + + a { + display: flex; + align-items: center; + padding: 8px 16px; + text-decoration: none; + color: #374151; + font-size: 14px; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f9fafb; + color: #3b82f6; + } + } + } + + // User avatar + .thredded--currently-online--avatar { + width: 32px; + height: 32px; + border-radius: 50%; + margin-right: 12px; + border: 2px solid #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); + } + + // Online indicator dot + .thredded--currently-online--user a::after { + content: ''; + width: 8px; + height: 8px; + background: #10b981; + border-radius: 50%; + margin-left: auto; + animation: pulse 2s infinite; + } + + // Scrollbar styling for user list + .thredded--currently-online--users::-webkit-scrollbar { + width: 6px; + } + + .thredded--currently-online--users::-webkit-scrollbar-track { + background: #f3f4f6; + border-radius: 3px; + } + + .thredded--currently-online--users::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; + + &:hover { + background: #9ca3af; + } + } +} + +// Pulse animation for online indicator +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); + } + 70% { + box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +// Dark mode support +.dark .thredded--currently-online { + background: #1f2937; + border-color: #374151; + box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.3); + + header { + background: #1e40af; // Darker blue for dark mode + + &:hover { + background: #1e3a8a; // Even darker on hover + } + } + + .thredded--currently-online--users { + background: #1f2937; + border-top-color: #374151; + } + + .thredded--currently-online--user a { + color: #d1d5db; + + &:hover { + background-color: #111827; + color: #a78bfa; + } + } + + .thredded--currently-online--avatar { + border-color: #10b981; + } +} + +// Mobile responsiveness +@media (max-width: 640px) { + .thredded--currently-online { + right: 10px; + min-width: 160px; + max-width: 200px; + + .thredded--currently-online--title { + font-size: 13px; + } + + .thredded--currently-online--user a { + font-size: 13px; + padding: 6px 12px; + } + + .thredded--currently-online--avatar { + width: 28px; + height: 28px; + margin-right: 10px; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/dark-mode.scss b/app/assets/stylesheets/dark-mode.scss index 9c8561763..7fb12750a 100644 --- a/app/assets/stylesheets/dark-mode.scss +++ b/app/assets/stylesheets/dark-mode.scss @@ -2,21 +2,21 @@ $transition-effects: color 1s ease, background-color 1s ease; body { background-color: #eee; - transition: $transition-effects; + transition: $transition-effects; &.dark { background-color: #202123; + border: 1px solid #202123; color: #fff; - nav { - background-color: #2196F3; - } - .card, .card-panel { + + .card, + .card-panel { background-color: rgba(255, 255, 255, 0.2); - .card-title { - color: #fff; - } + .card-title { + color: #fff; + } .card-content { @@ -24,26 +24,32 @@ body { color: #ddd; } } - } - .card-reveal { - background-color: #3B4043; } + + .card-reveal { + background-color: #3B4043; + } + .dropdown-content { background-color: #2D2D31; + a { color: #ccc; + &:hover { background-color: #3B4043; } } - li.optgroup > span { + li.optgroup>span { color: #ccc; } } - .modal { - background-color: #2D2D31; - } + + .modal { + background-color: #2D2D31; + } + .btn { background-color: #000000; border: 1px solid #aaa; @@ -51,51 +57,64 @@ body { border-bottom: 1px solid #888; color: #ccc; } + .divider { opacity: 0.2; } + .input-field { .helper-text { color: #ccc; } } - textarea, input { - color: #fff; - - &::placeholder { - color: #aaa; - } - } - .collapsible-header { + + textarea, + input { + color: #fff; + + &::placeholder { + color: #aaa; + } + } + + .collapsible-header { background-color: #3B4043; - + &:active { background: #2196F3; } - } - .sidenav, .collapsible-body { - background-color: #2D2D31; + } + + .sidenav, + .collapsible-body { + background-color: #333336; + border-right: 1px solid #333336; + li { a { - &:not(.subheader){ + &:not(.subheader) { color: #89B2F5; + &:hover { - background-color: #3B4043; + background-color: #404044; } } + &.subheader { - color:#9AA0A6; + color: #9AA0A6; } + .material-icons { color: #9AA0A6; } } } } + .collection { - border: 1px solid rgba(255,255,255,0.2); + border: 1px solid rgba(255, 255, 255, 0.2); background: black; - + .collection-header { background: #3B4043; } @@ -105,6 +124,7 @@ body { border-bottom: 1px solid rgba(255, 255, 255, 0.2); } } + #editor { background-color: #3B4043; color: white; @@ -118,7 +138,7 @@ body { background: #E3F2FD; div { - color: black; + color: black; } } @@ -127,13 +147,7 @@ body { color: white; } - .thredded--post--dropdown--actions--item { - color: black; - &:hover { - color: white; - } - } .thredded--currently-online { border: 1px solid black; @@ -144,7 +158,9 @@ body { } .thredded--form { - input, textarea { + + input, + textarea { color: white; background-color: #3B4043; @@ -163,7 +179,8 @@ body { } } - .thredded--messageboard, .thredded--form { + .thredded--messageboard, + .thredded--form { background-color: #3B4043; } @@ -171,7 +188,9 @@ body { color: white; } - .thredded--messageboard--byline, .thredded--messageboard--meta--counts, .thredded--navigation-breadcrumbs li a { + .thredded--messageboard--byline, + .thredded--messageboard--meta--counts, + .thredded--navigation-breadcrumbs li a { color: #9AA0A6; } @@ -183,12 +202,15 @@ body { color: #9AA0A6; } + /* Legacy styles removed for new design .thredded--topic-header { .thredded--topic-header--title { color: white; } - .thredded--topic-header--started-by, .thredded--topic-followers, .thredded--topic-header--follow-info--reason { + .thredded--topic-header--started-by, + .thredded--topic-followers, + .thredded--topic-header--follow-info--reason { color: #9AA0A6; a { @@ -212,7 +234,8 @@ body { border-bottom: 1px solid #333; border-top: 1px solid #666; } - + */ + .muted-thredded-post { color: lightgrey; background-color: #202123 !important; @@ -220,19 +243,32 @@ body { } nav.thredded--pagination { - background: #3B4043; - a { - color: lightgrey; + background-color: #1f2937; + border-color: #374151; - &:hover { + > span { + color: #9ca3af; + + > a { + color: #e5e7eb; + + &:hover, + &:focus { + color: #60a5fa; + } + } + + &.current { + background-color: #3b82f6; color: white; + border-radius: 4px; } } } .thredded--topics--topic { background-color: #3B4043; - + .thredded--topics--title a { color: white; } @@ -282,4 +318,4 @@ body { color: #fff !important; } } -} +} \ No newline at end of file diff --git a/app/assets/stylesheets/document_editor.scss b/app/assets/stylesheets/document_editor.scss new file mode 100644 index 000000000..07e7c4c1e --- /dev/null +++ b/app/assets/stylesheets/document_editor.scss @@ -0,0 +1,179 @@ +// Document Editor Styles +// Custom styles for the Medium Editor-based document editor. +// Designed to work with Tailwind CSS and dark mode. + +// Remove Medium Editor's default border/box from editable area +.medium-editor-element { + border: none !important; + box-shadow: none !important; + padding: 0 !important; + outline: none !important; + background: transparent !important; +} + +// Align title input with editor content - strip browser default padding +#document_title { + padding: 0; +} + +// Enhanced Medium Editor toolbar styling +.medium-editor-toolbar { + border-radius: 0.5rem !important; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important; + border: none !important; + background-color: white !important; + transition: all 0.2s ease; + transform-origin: center bottom; + animation: toolbar-appear 0.2s ease; + max-width: calc(100vw - 2rem) !important; + overflow: hidden !important; +} + +.dark .medium-editor-toolbar { + background-color: #1f2937 !important; // gray-800 + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.3) !important; +} + +// Style the toolbar arrow +.medium-editor-toolbar:after { + border-top-color: white !important; +} + +.dark .medium-editor-toolbar:after { + border-top-color: #1f2937 !important; +} + +@keyframes toolbar-appear { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.medium-editor-toolbar-active { + visibility: visible; +} + +.medium-editor-toolbar-actions { + display: flex !important; + padding: 4px !important; + gap: 2px; + flex-wrap: wrap; +} + +.medium-editor-action { + width: 36px !important; + height: 36px !important; + border-radius: 0.375rem !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: all 0.15s ease; + background-color: white !important; + color: #14b8a6 !important; // teal-500 + border: none !important; +} + +.dark .medium-editor-action { + background-color: #1f2937 !important; // gray-800 + color: #5eead4 !important; // teal-300 +} + +.medium-editor-action:hover { + background-color: rgba(240, 253, 250, 1) !important; // teal-50 + color: #0d9488 !important; // teal-600 +} + +.dark .medium-editor-action:hover { + background-color: #374151 !important; // gray-700 + color: #5eead4 !important; // teal-300 +} + +.medium-editor-button-active { + background-color: rgba(204, 251, 241, 1) !important; // teal-100 + color: #0f766e !important; // teal-700 +} + +.dark .medium-editor-button-active { + background-color: rgba(20, 184, 166, 0.2) !important; // teal-500/20 + color: #5eead4 !important; // teal-300 +} + +// Muted text selection highlight +#editor ::selection { + background-color: rgba(204, 251, 241, 0.6) !important; // teal-100 with opacity +} + +.dark #editor ::selection { + background-color: rgba(20, 184, 166, 0.3) !important; // teal-500 with opacity +} + +.medium-editor-placeholder::after { + font-style: normal; + color: rgba(156, 163, 175, 1); + padding: 0; + margin: 0; +} + +.dark .medium-editor-placeholder::after { + color: rgba(107, 114, 128, 1); // gray-500 +} + +// Improve the prose styling for the editor +.prose { + max-width: none; +} + +.prose h2 { + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.prose h3 { + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +.prose p { + margin-top: 1em; + margin-bottom: 1em; +} + +.prose ul, .prose ol { + margin-top: 1em; + margin-bottom: 1em; +} + +// Autosave indicator animation +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.6; } + 100% { opacity: 1; } +} + +.pulse-animation { + animation: pulse 1.5s infinite ease-in-out; +} + +// Remove focus outline from editor to prevent misalignment with parent border +#editor:focus, +#editor:focus-visible { + outline: none !important; + box-shadow: none !important; +} + +// Force long words/strings to break to prevent horizontal overflow +#editor { + overflow-wrap: break-word; + word-break: break-word; +} + +// Mobile responsiveness +@media (max-width: 640px) { + .medium-editor-action { + width: 32px; + height: 32px; + } + + #editor { + padding: 1rem; + } +} diff --git a/app/assets/stylesheets/editor.scss b/app/assets/stylesheets/editor.scss index 184848637..09f78b430 100644 --- a/app/assets/stylesheets/editor.scss +++ b/app/assets/stylesheets/editor.scss @@ -6,7 +6,7 @@ margin-top: 20px; margin-bottom: 400px; - border: 1px solid #dedede; + border: 1px solid white; padding: 5px 0; color: black; @@ -22,7 +22,6 @@ /* PAGES */ background: white; padding: 30px; - border-bottom: 1px solid grey; &:focus { border: 1px solid #dedede; @@ -51,6 +50,15 @@ list-style-type: disc; } } + + ol { + list-style-type: decimal; + padding-left: 40px; + + li { + list-style-type: decimal; + } + } } .mobile-navbar-spacer { diff --git a/app/assets/stylesheets/forum-messageboards.scss b/app/assets/stylesheets/forum-messageboards.scss new file mode 100644 index 000000000..d3b85b3e3 --- /dev/null +++ b/app/assets/stylesheets/forum-messageboards.scss @@ -0,0 +1,270 @@ +// Forum Messageboards - Professional Literary Design +// Minimal, sophisticated design for professional and indie authors + +// Messageboard card - clean manuscript aesthetic +.messageboard-card { + position: relative; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 3px; + transition: all 0.15s ease; + overflow: hidden; + + // Subtle depth indicator - hint at stack without being literal + border-bottom: 2px solid #e5e7eb; + + // Clean, professional shadow + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + + // Refined hover state - subtle elevation + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + border-bottom-color: #d1d5db; + + h3 { + color: #3b82f6; // notebook-blue on hover for title + } + } + + // Active/focus state + &:focus-within { + outline: 2px solid #3b82f6; + outline-offset: -1px; + } +} + +// Compact, professional spacing +.messageboard-compact { + + // Header section + .messageboard-header { + padding: 0.875rem; // 14px + + h3 { + font-size: 0.9375rem; // 15px + line-height: 1.25rem; + font-weight: 600; + margin: 0; + color: #111827; + transition: color 0.15s ease; + letter-spacing: -0.01em; + } + } + + // Description - subtle and professional + .messageboard-description { + font-size: 0.8125rem; // 13px + line-height: 1.25rem; + margin: 0.375rem 0; + color: #6b7280; + + // Single line with ellipsis + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // Stats section - minimal + .messageboard-stats { + font-size: 0.75rem; // 12px + color: #9ca3af; + margin-top: 0.625rem; + font-weight: 500; + letter-spacing: 0.025em; + + .material-icons { + font-size: 0.875rem; // 14px + vertical-align: text-bottom; + opacity: 0.7; + } + } + + // Footer - subtle separator + .messageboard-footer { + padding: 0.625rem 0.875rem; // 10px 14px + background: #fafafa; + border-top: 1px solid #f3f4f6; + font-size: 0.75rem; // 12px + color: #6b7280; + + span { + font-weight: 500; + } + } +} + +// Group titles - typography-focused +.messageboard-group-title { + margin: 1.25rem 0 0.875rem 0; + font-size: 0.75rem; // 12px + font-weight: 700; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + + // Remove decorative line - let typography do the work + &::before { + display: none; + } + + // First group spacing + &:first-child { + margin-top: 0; + } + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: #4b5563; + } + } +} + +// Grid with professional spacing +.messageboards-grid { + gap: 1rem; // 16px - gives breathing room + + @media (min-width: 768px) { + gap: 1.25rem; // 20px on larger screens + } +} + +// Locked indicator - subtle and inline +.messageboard-locked { + .messageboard-header h3 { + .material-icons { + font-size: 0.875rem; + opacity: 0.5; + vertical-align: middle; + margin-right: 0.25rem; + } + } +} + +// Unread badge - minimal +.messageboard-unread-badge { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; // 11px + font-weight: 600; + border-radius: 3px; + background: #f3f4f6; + color: #4b5563; + + // Blue for followed topics + &.followed { + background: #eff6ff; + color: #3b82f6; + } + + // Blue for new topics + &.new { + background: #eff6ff; + color: #2563eb; + } +} + +// Page header - clean and professional +.forum-page-header { + padding: 1.5rem 0 1rem; + border-bottom: 1px solid #f3f4f6; + margin-bottom: 1.5rem; + + h1 { + font-size: 1.75rem; // 28px + line-height: 2.25rem; + font-weight: 700; + letter-spacing: -0.025em; + color: #111827; + + .material-icons { + font-size: 1.75rem; + opacity: 0.8; + vertical-align: middle; + margin-right: 0.5rem; + } + } + + p { + font-size: 0.9375rem; // 15px + margin-top: 0.375rem; + color: #6b7280; + } +} + +// Dark mode - maintain professionalism +.dark { + .messageboard-card { + background: #1f2937; + border-color: #374151; + border-bottom-color: #374151; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + + &:hover { + background: #374151; // lighter gray on hover + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + border-bottom-color: #4b5563; + + h3 { + color: #a78bfa; + } + } + } + + .messageboard-compact { + .messageboard-header h3 { + color: #f3f4f6; + } + + .messageboard-description { + color: #9ca3af; + } + + .messageboard-stats { + color: #6b7280; + } + + .messageboard-footer { + background: #111827; + border-top-color: #374151; + color: #9ca3af; + } + } + + .messageboard-group-title { + color: #9ca3af; + + a:hover { + color: #d1d5db; + } + } + + .messageboard-unread-badge { + background: #374151; + color: #d1d5db; + + &.followed { + background: rgba(167, 139, 250, 0.1); + color: #a78bfa; + } + + &.new { + background: rgba(59, 130, 246, 0.1); + color: #60a5fa; + } + } + + .forum-page-header { + border-bottom-color: #374151; + + h1 { + color: #f3f4f6; + } + + p { + color: #9ca3af; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/forum-navigation.scss b/app/assets/stylesheets/forum-navigation.scss new file mode 100644 index 000000000..f20cff6f0 --- /dev/null +++ b/app/assets/stylesheets/forum-navigation.scss @@ -0,0 +1,57 @@ +// Forum Navigation Styling +// This file contains minimal styling for forum navigation elements +// that can't be easily styled with Tailwind classes + +.thredded-nav { + // Ensure nav items are properly contained + ul { + display: flex; + align-items: center; + height: 100%; + margin: 0; + padding: 0; + list-style: none; + } + + li { + display: flex; + align-items: center; + height: 100%; + margin: 0; + } + + // Style for navigation icons + .material-icons { + font-size: 18px; + vertical-align: middle; + } + + // Ensure links fill their containers + a { + display: flex; + align-items: center; + height: 100%; + text-decoration: none; + } + + // Current page indicator + .thredded--is-current { + background-color: rgba(59, 130, 246, 0.1); // blue-50 equivalent + border-bottom: 2px solid rgb(59, 130, 246); // blue-500 + } +} + +// Ensure the thredded container doesn't overflow +#thredded--container { + max-width: 100%; +} + +.thredded--container { + max-width: 100%; +} + +// Forum post content sizing +.thredded--post--content { + font-size: 14px; + line-height: 1.6; +} \ No newline at end of file diff --git a/app/assets/stylesheets/forum-topics.scss b/app/assets/stylesheets/forum-topics.scss new file mode 100644 index 000000000..e86896e1f --- /dev/null +++ b/app/assets/stylesheets/forum-topics.scss @@ -0,0 +1,423 @@ +// Forum Topics - Collapsible New Discussion Form +// Progressive disclosure pattern for starting new discussions + +// New topic form container +.thredded--new-topic-form { + position: relative; + margin-bottom: 1.5rem; + transition: all 0.3s ease; + + // Wrapper for Alpine.js states + &[x-data] { + // Initial collapsed state styles handled by Alpine + } +} + +// Collapsed state - single input field +.new-topic-collapsed { + position: relative; + + .collapsed-input { + width: 100%; + padding: 1.5rem 1rem 1.5rem 2.75rem; + font-size: 0.9375rem; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + cursor: text; + transition: all 0.2s ease; + color: #6b7280; + + &:hover { + border-color: #d1d5db; + background: #fafafa; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + } + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + color: #111827; + } + + // Icon + &::before { + content: ''; + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + width: 1.25rem; + height: 1.25rem; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E") no-repeat center; + background-size: contain; + } + } + + // Fake placeholder + .collapsed-placeholder { + position: absolute; + left: 2.75rem; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + pointer-events: none; + transition: opacity 0.2s ease; + } +} + +// Expanded state - full form +.new-topic-expanded { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + overflow: hidden; + + // Form header + .form-header { + padding: 1rem 1.25rem 0.75rem; + border-bottom: 1px solid #f3f4f6; + background: #fafafa; + + .form-title { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + display: flex; + align-items: center; + + .material-icons { + font-size: 1rem; + margin-right: 0.5rem; + opacity: 0.7; + } + } + } + + // Form content area + .form-content { + padding: 1.25rem; + } + + // Override thredded's default form list styles + .thredded--form-list { + margin: 0; + padding: 0; + list-style: none; + + li { + margin-bottom: 1rem; + + &:last-child { + margin-bottom: 0; + } + } + + // Title field + li.title { + input[type="text"] { + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 1rem; + border: 1px solid #d1d5db; + border-radius: 4px; + transition: all 0.15s ease; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + + &::placeholder { + color: #9ca3af; + } + } + + label { + display: block; + font-weight: 500; + color: #4b5563; + margin-bottom: 0.375rem; + } + } + + // Content textarea + textarea { + width: 100%; + min-height: 120px; + padding: 0.625rem 0.875rem; + font-size: 0.9375rem; + border: 1px solid #d1d5db; + border-radius: 4px; + resize: vertical; + transition: all 0.15s ease; + font-family: inherit; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + min-height: 150px; + } + + &::placeholder { + color: #9ca3af; + } + } + + // Category select + li.category { + select { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border: 1px solid #d1d5db; + border-radius: 4px; + background: white; + cursor: pointer; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + } + } + } + + // Form actions + .form-actions { + padding: 1rem 1.25rem; + background: #fafafa; + border-top: 1px solid #f3f4f6; + display: flex; + justify-content: space-between; + align-items: center; + + .left-actions { + display: flex; + gap: 0.5rem; + } + + .right-actions { + display: flex; + gap: 0.5rem; + } + + // Buttons + .thredded--form--submit { + padding: 0.5rem 1.25rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 4px; + border: none; + cursor: pointer; + transition: all 0.15s ease; + background: #3b82f6; + color: white; + + &:hover:not(:disabled) { + background: #2563eb; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .btn-cancel { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 4px; + border: 1px solid #d1d5db; + background: white; + color: #6b7280; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #f9fafb; + border-color: #9ca3af; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(107, 114, 128, 0.1); + } + } + + .btn-preview { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 4px; + border: 1px solid #e5e7eb; + background: white; + color: #6b7280; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + + .material-icons { + font-size: 1rem; + margin-right: 0.25rem; + } + + &:hover { + background: #f9fafb; + } + + &.active { + background: #eff6ff; + border-color: #3b82f6; + color: #2563eb; + } + } + } +} + +// Preview area +.thredded--preview-area { + margin: 1rem 0; + padding: 1rem; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 4px; + min-height: 100px; + + &:empty::before { + content: 'Preview will appear here...'; + color: #9ca3af; + font-style: italic; + } +} + +// Transition classes for smooth animations +.form-expand-enter { + opacity: 0; + transform: translateY(-10px); +} + +.form-expand-enter-active { + opacity: 1; + transform: translateY(0); + transition: all 0.3s ease; +} + +.form-expand-leave { + opacity: 1; + transform: translateY(0); +} + +.form-expand-leave-active { + opacity: 0; + transform: translateY(-10px); + transition: all 0.2s ease; +} + +// Dark mode support +.dark { + .new-topic-collapsed { + .collapsed-input { + background: #1f2937; + border-color: #374151; + color: #9ca3af; + + &:hover { + background: #111827; + border-color: #4b5563; + } + + &:focus { + border-color: #3b82f6; + color: #f3f4f6; + } + } + + .collapsed-placeholder { + color: #6b7280; + } + } + + .new-topic-expanded { + background: #1f2937; + border-color: #374151; + + .form-header { + background: #1f2937; // Match container background for seamless look + border-bottom-color: #374151; + padding-bottom: 1rem; + + .form-title { + color: #f3f4f6; + } + } + + .thredded--form-list { + + input[type="text"], + textarea, + select { + background: #111827; // Darker than container + border-color: #4b5563; // Lighter border for visibility + color: #f3f4f6; // High contrast text + + &:focus { + border-color: #60a5fa; // Brighter blue + background: #0f172a; // Even darker on focus + } + + &::placeholder { + color: #9ca3af; // Lighter placeholder + } + } + + label { + color: #e5e7eb !important; // Force override for now, or match specificity + font-weight: 600; + font-size: 0.875rem; // Standardize font size + } + + li.title label { + color: #e5e7eb; + } + } + + .form-actions { + background: #111827; + border-top-color: #374151; + + .btn-cancel, + .btn-preview { + background: #1f2937; + border-color: #374151; + color: #9ca3af; + + &:hover { + background: #374151; + } + } + } + } + + .thredded--preview-area { + background: #111827; + border-color: #374151; + color: #d1d5db; + + &:empty::before { + color: #6b7280; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/iconography.scss b/app/assets/stylesheets/iconography.scss new file mode 100644 index 000000000..90eedf4d2 --- /dev/null +++ b/app/assets/stylesheets/iconography.scss @@ -0,0 +1,15 @@ +.svg-icon { + width: 1em; + height: 1em; +} + +.svg-icon path, +.svg-icon polygon, +.svg-icon rect { + fill: #333; +} + +.svg-icon circle { + stroke: #4691f6; + stroke-width: 1; +} \ No newline at end of file diff --git a/app/assets/stylesheets/materialize-overrides.scss b/app/assets/stylesheets/materialize-overrides.scss deleted file mode 100644 index ac8033b03..000000000 --- a/app/assets/stylesheets/materialize-overrides.scss +++ /dev/null @@ -1,66 +0,0 @@ -/* This might be an issue later. */ -.row .col { - padding: 0 0.25rem !important; -} - -.select-wrapper input.select-dropdown { - z-index: 0 !important; -} - -.sidenav li { - overflow: hidden; -} - -#recent-edits-sidenav { - min-width: 20%; -} - -.content-page-list { - .card.horizontal { - .card-image { - img { - min-width: 200px; - max-width: 300px; - } - } - } -} - -@media only screen and (min-width: 1024px) { - .fixed-card-content - { - height: 12em; - } -} - -@media only screen and (max-width: 1024px) { - .fixed-card-content - { - height: 12em; - } -} - -@media only screen and (min-width: 1448px) { - .fixed-card-content - { - height: 9em; - } -} - -body { - background: #f4f4f4; -} - -.sidenav-trigger { - margin: 0 !important; -} - -/* This is a hack to fix dropdowns working on iOS devices */ -.dropdown-content { - transform: none !important; -} - -.flex { - display: flex; - flex-wrap: wrap; -} \ No newline at end of file diff --git a/app/assets/stylesheets/moderation.scss b/app/assets/stylesheets/moderation.scss new file mode 100644 index 000000000..219448f70 --- /dev/null +++ b/app/assets/stylesheets/moderation.scss @@ -0,0 +1,1815 @@ +// Moderation Dashboard Styling +// Modern admin interface for forum moderation + +// Main moderation container +.thredded--pending-moderation, +.thredded--moderation-history, +.thredded--moderation-activity, +.thredded--moderation-users { + + // Override default spacing + .thredded--main-section { + padding: 0; + } +} + +// Moderation Navigation - Tab-style design +.thredded--moderation-navigation { + background: white; + border-bottom: 2px solid #e5e7eb; + padding: 0 1.5rem; + margin-bottom: 2rem; + margin-top: 3rem; + + .thredded--moderation-navigation--items { + display: flex; + gap: 2rem; + margin: 0; + padding: 0; + list-style: none; + + .thredded--moderation-navigation--item { + position: relative; + + a { + display: block; + padding: 1rem 0; + color: #6b7280; + text-decoration: none; + font-weight: 500; + transition: all 0.15s ease; + border-bottom: 3px solid transparent; + + &:hover { + color: #2196F3; + } + + // Active tab indicator + &.active, + &[aria-current="page"] { + color: #2196F3; + border-bottom-color: #2196F3; + } + } + } + } + + // User search form in nav + form { + position: absolute; + right: 1.5rem; + top: 0.75rem; + + input[type="search"] { + padding: 0.5rem 1rem; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 0.875rem; + width: 200px; + transition: all 0.15s ease; + + &:focus { + outline: none; + border-color: #2196F3; + box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); + width: 250px; + } + + &::placeholder { + color: #9ca3af; + } + } + } +} + +// Moderation stats dashboard +.moderation-dashboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; + padding-top: 1rem; + + .stat-card { + background: white; + border-radius: 8px; + padding: 1.5rem; + border: 1px solid #e5e7eb; + transition: all 0.15s ease; + + &:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); + transform: translateY(-2px); + } + + .stat-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; + + .material-icons { + font-size: 24px; + } + + &.pending { + background: #fef3c7; + color: #f59e0b; + } + + &.approved { + background: #d1fae5; + color: #10b981; + } + + &.blocked { + background: #fee2e2; + color: #ef4444; + } + + &.users { + background: #ddd6fe; + color: #8b5cf6; + } + } + + .stat-value { + font-size: 2rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.25rem; + } + + .stat-label { + font-size: 0.875rem; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .stat-change { + margin-top: 0.5rem; + font-size: 0.8125rem; + + &.positive { + color: #10b981; + + &::before { + content: '↑ '; + } + } + + &.negative { + color: #ef4444; + + &::before { + content: '↓ '; + } + } + } + } +} + +// Moderated posts list +.moderation-posts { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + overflow: hidden; + + .moderation-posts-header { + padding: 1.25rem 1.5rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #111827; + display: flex; + align-items: center; + + .material-icons { + margin-right: 0.5rem; + color: #6b7280; + } + } + + .filter-buttons { + display: flex; + gap: 0.5rem; + + button { + padding: 0.375rem 0.75rem; + border: 1px solid #e5e7eb; + background: white; + border-radius: 6px; + font-size: 0.8125rem; + color: #6b7280; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #f9fafb; + } + + &.active { + background: #2196F3; + color: white; + border-color: #2196F3; + } + } + } + } + + .moderation-post { + border-bottom: 1px solid #f3f4f6; + padding: 1.5rem; + transition: background 0.15s ease; + + &:hover { + background: #fafbfc; + } + + &:last-child { + border-bottom: none; + } + + .post-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + + .post-meta { + display: flex; + align-items: center; + gap: 1rem; + + .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #e5e7eb; + } + + .user-info { + .username { + font-weight: 600; + color: #111827; + display: block; + margin-bottom: 0.125rem; + } + + .post-time { + font-size: 0.8125rem; + color: #6b7280; + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: #2196F3; + } + } + } + } + } + + .post-status { + padding: 0.25rem 0.75rem; + border-radius: 16px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + + &.pending { + background: #fef3c7; + color: #92400e; + } + + &.approved { + background: #d1fae5; + color: #065f46; + } + + &.blocked { + background: #fee2e2; + color: #991b1b; + } + } + } + + .post-content { + margin: 1rem 0; + padding-left: 3.25rem; + + .content-preview { + color: #374151; + line-height: 1.6; + max-height: 4.8em; + overflow: hidden; + position: relative; + + &.expanded { + max-height: none; + } + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2rem; + background: linear-gradient(transparent, white); + } + + &.expanded::after { + display: none; + } + } + + .expand-button { + margin-top: 0.5rem; + color: #2196F3; + font-size: 0.875rem; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + } + + .post-actions { + padding-left: 3.25rem; + display: flex; + gap: 1rem; + + button { + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 0.375rem; + + .material-icons { + font-size: 16px; + } + + &.approve-btn { + background: #10b981; + color: white; + border: 1px solid #10b981; + + &:hover { + background: #059669; + border-color: #059669; + } + } + + &.block-btn { + background: white; + color: #ef4444; + border: 1px solid #ef4444; + + &:hover { + background: #fee2e2; + } + } + + &.more-btn { + background: white; + color: #6b7280; + border: 1px solid #e5e7eb; + + &:hover { + background: #f9fafb; + } + } + } + } + } +} + +// Empty state +.thredded--empty { + text-align: center; + padding: 3rem 1.5rem; + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + + .thredded--empty--title { + color: #111827; + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + p { + color: #6b7280; + max-width: 400px; + margin: 0 auto; + } + + .empty-icon { + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + background: #f3f4f6; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + .material-icons { + font-size: 40px; + color: #9ca3af; + } + } +} + +// Moderation notice banner +.thredded--moderated-notice { + background: #f0fdf4; + border: 1px solid #86efac; + border-radius: 8px; + padding: 1rem 1.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; + + .material-icons { + color: #10b981; + font-size: 20px; + } + + .notice-content { + flex: 1; + + .notice-title { + font-weight: 600; + color: #065f46; + margin-bottom: 0.25rem; + } + + .notice-text { + font-size: 0.875rem; + color: #047857; + } + } + + .notice-dismiss { + padding: 0.25rem; + background: transparent; + border: none; + cursor: pointer; + color: #10b981; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.7; + } + } +} + +// Moderation History Page +.moderation-history-header { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + padding: 1.5rem; + margin-bottom: 2rem; + + .history-title { + display: flex; + align-items: center; + margin-bottom: 1.5rem; + + .material-icons { + font-size: 28px; + color: #6b7280; + margin-right: 0.75rem; + } + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: #111827; + } + } + + .history-filters { + display: flex; + gap: 1.5rem; + flex-wrap: wrap; + + .filter-group { + display: flex; + align-items: center; + gap: 0.5rem; + + label { + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; + } + + .filter-select { + padding: 0.5rem 2rem 0.5rem 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 0.875rem; + color: #374151; + background: white; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 0.5rem center; + background-size: 1rem; + appearance: none; + cursor: pointer; + transition: all 0.15s ease; + + &:focus { + outline: none; + border-color: #2196F3; + box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); + } + } + } + } +} + +// Timeline Layout +.moderation-timeline { + position: relative; + padding-left: 3rem; + + // Vertical line + &::before { + content: ''; + position: absolute; + left: 1.25rem; + top: 0; + bottom: 0; + width: 2px; + background: #e5e7eb; + } + + .timeline-item { + position: relative; + margin-bottom: 2rem; + + &:last-child { + margin-bottom: 0; + } + + // Timeline marker dot + .timeline-marker { + position: absolute; + left: -2rem; + top: 0.5rem; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + background: white; + border: 2px solid #e5e7eb; + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + + .material-icons { + font-size: 20px; + } + } + + &.approved .timeline-marker { + border-color: #10b981; + background: #ecfdf5; + + .material-icons { + color: #10b981; + } + } + + &.blocked .timeline-marker { + border-color: #ef4444; + background: #fef2f2; + + .material-icons { + color: #ef4444; + } + } + + // Timeline content card + .timeline-content { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + transition: all 0.15s ease; + + &:hover { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05); + } + + .action-header { + padding: 1rem 1.25rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; + + .action-info { + display: flex; + align-items: center; + gap: 0.75rem; + + .action-type { + padding: 0.25rem 0.625rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + &.approved { + background: #d1fae5; + color: #065f46; + } + + &.blocked { + background: #fee2e2; + color: #991b1b; + } + } + + .action-meta { + font-size: 0.875rem; + color: #6b7280; + + a { + color: #2196F3; + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + + .separator { + margin: 0 0.375rem; + color: #d1d5db; + } + } + } + + .action-links { + .view-post-link { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 0.8125rem; + color: #6b7280; + text-decoration: none; + transition: all 0.15s ease; + + &:hover { + background: white; + color: #2196F3; + border-color: #2196F3; + } + + .material-icons { + font-size: 14px; + } + } + } + } + + .post-preview { + padding: 1.25rem; + + .post-author { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 1rem; + + .author-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #e5e7eb; + flex-shrink: 0; + } + + .author-info { + .author-name { + font-weight: 600; + color: #111827; + margin-bottom: 0.125rem; + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: #2196F3; + } + } + } + + .post-location { + font-size: 0.8125rem; + color: #6b7280; + + a { + color: #2196F3; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + em { + color: #9ca3af; + font-style: normal; + } + } + } + } + + .post-content-preview { + .content-changed-notice { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + background: #fef3c7; + border: 1px solid #fbbf24; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.8125rem; + color: #92400e; + + .material-icons { + font-size: 16px; + color: #f59e0b; + } + } + + .content-text { + color: #374151; + line-height: 1.6; + font-size: 0.9375rem; + } + } + + .post-actions { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #f3f4f6; + } + } + } + } +} + +// Moderation Activity Page +.activity-header { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + padding: 1.5rem; + margin-bottom: 1.5rem; + + .activity-title { + display: flex; + align-items: center; + + .material-icons { + font-size: 28px; + color: #6b7280; + margin-right: 0.75rem; + } + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: #111827; + } + } +} + +// Activity Feed +.activity-feed { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + overflow: hidden; + + .feed-header { + padding: 1.25rem 1.5rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + + h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #111827; + display: flex; + align-items: center; + + .material-icons { + margin-right: 0.5rem; + color: #6b7280; + font-size: 20px; + } + } + } + + .feed-items { + .feed-item { + display: flex; + gap: 1rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid #f3f4f6; + transition: background 0.15s ease; + position: relative; + + &:hover { + background: #fafbfc; + } + + &:last-child { + border-bottom: none; + } + + .feed-item-indicator { + flex-shrink: 0; + + .material-icons { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + + &.new-topic { + background: #dbeafe; + color: #2563eb; + } + + &.reply { + background: #f3f4f6; + color: #6b7280; + } + } + } + + .feed-item-content { + flex: 1; + min-width: 0; + + .feed-meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.875rem; + flex-wrap: wrap; + + .feed-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid #e5e7eb; + } + + .feed-user { + font-weight: 600; + color: #111827; + + a { + color: inherit; + text-decoration: none; + + &:hover { + color: #2196F3; + } + } + } + + .feed-action { + color: #6b7280; + } + + .feed-location { + color: #6b7280; + + .feed-topic-link { + color: #2196F3; + text-decoration: none; + font-weight: 500; + + &:hover { + text-decoration: underline; + } + } + } + + .feed-time { + color: #9ca3af; + margin-left: auto; + } + } + + .feed-preview { + color: #374151; + line-height: 1.5; + margin-bottom: 0.75rem; + } + + .feed-actions { + display: flex; + gap: 0.5rem; + + .feed-action-btn { + padding: 0.25rem 0.625rem; + border: 1px solid #e5e7eb; + background: white; + border-radius: 4px; + font-size: 0.75rem; + color: #6b7280; + cursor: pointer; + text-decoration: none; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 0.25rem; + + .material-icons { + font-size: 14px; + } + + &:hover { + background: #f9fafb; + color: #374151; + } + + &.moderate { + color: #2196F3; + border-color: #2196F3; + + &:hover { + background: #2196F3; + color: white; + } + } + } + } + } + + .feed-item-status { + position: absolute; + top: 1.25rem; + right: 1.5rem; + + .status-badge { + padding: 0.25rem 0.625rem; + border-radius: 12px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + + &.pending { + background: #fef3c7; + color: #92400e; + } + + &.blocked { + background: #fee2e2; + color: #991b1b; + } + } + } + } + } +} + +// Dark mode support +.dark { + .thredded--moderation-navigation { + background: #1f2937; + border-bottom-color: #374151; + + .thredded--moderation-navigation--item a { + color: #9ca3af; + + &:hover { + color: #42a5f5; + } + + &.active { + color: #42a5f5; + border-bottom-color: #42a5f5; + } + } + + input[type="search"] { + background: #111827; + border-color: #374151; + color: #f3f4f6; + + &:focus { + border-color: #42a5f5; + box-shadow: 0 0 0 3px rgba(66, 165, 245, 0.1); + } + } + } + + .stat-card { + background: #1f2937; + border-color: #374151; + + .stat-value { + color: #f3f4f6; + } + } + + .moderation-posts { + background: #1f2937; + border-color: #374151; + + .moderation-posts-header { + background: #111827; + border-bottom-color: #374151; + + h3 { + color: #f3f4f6; + } + + .filter-buttons button { + background: #1f2937; + border-color: #374151; + color: #9ca3af; + + &:hover { + background: #374151; + } + + &.active { + background: #2196F3; + color: white; + } + } + } + + .moderation-post { + border-bottom-color: #374151; + + &:hover { + background: #111827; + } + + .post-header .user-info .username { + color: #f3f4f6; + } + + .post-content .content-preview { + color: #d1d5db; + + &::after { + background: linear-gradient(transparent, #1f2937); + } + } + } + } + + // Activity Page + .thredded--moderation-activity { + .thredded--main-section { + background: #111827; + } + + .activity-header { + background: #1f2937; + border-color: #374151; + + .activity-title { + h2 { + color: #f3f4f6; + } + } + } + + .thredded--moderated-notice { + background: #1f2937; + border-color: #374151; + color: #f3f4f6; + + .notice-title { + color: #f3f4f6; + } + + .notice-text { + color: #d1d5db; + } + } + + .activity-feed { + background: #1f2937; + border-color: #374151; + + .feed-header { + background: #111827; + border-bottom-color: #374151; + + h3 { + color: #f3f4f6; + } + } + + .feed-items { + .feed-item { + border-bottom-color: #374151; + + &:hover { + background: #111827; + } + + .feed-item-content { + .feed-meta { + .feed-user { + color: #f3f4f6; + } + + .feed-action { + color: #9ca3af; + } + + .feed-location { + .feed-topic-link { + color: #42a5f5; + } + } + } + + .feed-preview { + color: #d1d5db; + } + + .feed-actions { + .feed-action-btn { + background: #1f2937; + border-color: #374151; + color: #9ca3af; + + &:hover { + background: #374151; + color: #f3f4f6; + } + + &.moderate { + color: #42a5f5; + border-color: #42a5f5; + + &:hover { + background: #42a5f5; + color: white; + } + } + } + } + } + } + } + + .thredded--empty { + .thredded--empty--title { + color: #f3f4f6; + } + + p { + color: #9ca3af; + } + } + } + } + + // Users Management Page - Dark Mode + .thredded--moderation-users { + .thredded--main-section { + background: #111827; + } + + .users-header { + background: #1f2937; + border-color: #374151; + + .users-title { + .material-icons { + color: #9ca3af; + } + + h2 { + color: #f3f4f6; + } + } + + .users-search { + .search-input-wrapper { + background: #111827; + border-color: #374151; + + &:focus-within { + border-color: #42a5f5; + background: #111827; + } + + .material-icons { + color: #6b7280; + } + + .search-input { + color: #f3f4f6; + + &::placeholder { + color: #6b7280; + } + } + + .search-button { + background: #2196F3; + + &:hover { + background: #1976D2; + } + } + } + } + } + + .search-results-notice { + background: #1e3a8a; + border-color: #1d4ed8; + color: #bfdbfe; + + .material-icons { + color: #60a5fa; + } + } + + .users-table-container { + background: #1f2937; + border-color: #374151; + + .table-caption { + background: #111827; + border-bottom-color: #374151; + color: #9ca3af; + } + + .users-table { + thead { + background: #111827; + + tr { + border-bottom-color: #374151; + } + + th { + color: #9ca3af; + + .material-icons { + color: #6b7280; + } + } + } + + tbody { + .user-row { + border-bottom-color: #374151; + + &:hover { + background: #111827; + } + } + + td { + color: #d1d5db; + + &.user-cell { + .user-info { + .user-avatar { + border-color: #374151; + } + + .user-avatar-placeholder { + background: #374151; + + .material-icons { + color: #9ca3af; + } + } + + .user-details { + .user-name { + color: #f3f4f6; + + &:hover { + color: #42a5f5; + } + } + + .user-meta { + color: #9ca3af; + } + } + } + } + + &.status-cell { + .moderation-badge { + &.approved { + background: rgba(16, 185, 129, 0.2); + color: #34d399; + + .material-icons { + color: #34d399; + } + } + + &.blocked { + background: rgba(239, 68, 68, 0.2); + color: #f87171; + + .material-icons { + color: #f87171; + } + } + + &.pending_moderation { + background: rgba(245, 158, 11, 0.2); + color: #fbbf24; + + .material-icons { + color: #fbbf24; + } + } + } + } + + &.updated-cell { + color: #9ca3af; + } + + &.actions-cell { + .action-button { + background: #1f2937; + border-color: #374151; + color: #9ca3af; + + &:hover { + border-color: #42a5f5; + color: #42a5f5; + background: rgba(66, 165, 245, 0.1); + } + } + } + } + } + } + } + + .no-results { + background: #1f2937; + border-color: #374151; + + .no-results-icon { + .material-icons { + color: #4b5563; + } + } + + h3 { + color: #f3f4f6; + } + + p { + color: #9ca3af; + } + + .clear-search-btn { + background: #374151; + border-color: #4b5563; + color: #d1d5db; + + &:hover { + background: #4b5563; + border-color: #6b7280; + color: #f3f4f6; + } + } + } + } +} + +// Users Management Page - Light Mode +.users-header { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + padding: 1.5rem; + margin-bottom: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + + .users-title { + display: flex; + align-items: center; + + .material-icons { + font-size: 28px; + color: #6b7280; + margin-right: 0.75rem; + } + + h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: #111827; + } + } + + .users-search { + flex: 1; + max-width: 400px; + + .search-form { + width: 100%; + } + + .search-input-wrapper { + display: flex; + align-items: center; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 0.5rem 0.75rem; + transition: all 0.2s ease; + + &:focus-within { + border-color: #2196F3; + background: white; + box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1); + } + + .material-icons { + color: #9ca3af; + margin-right: 0.5rem; + font-size: 20px; + } + + .search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 0.875rem; + color: #374151; + + &::placeholder { + color: #9ca3af; + } + } + + .search-button { + padding: 0.375rem 0.875rem; + background: #2196F3; + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease; + + &:hover { + background: #1976D2; + } + } + } + } +} + +.search-results-notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.875rem; + color: #1e40af; + + .material-icons { + font-size: 18px; + } +} + +.users-table-container { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + overflow: hidden; + + .table-caption { + padding: 0.75rem 1rem; + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; + font-size: 0.875rem; + color: #6b7280; + text-align: left; + } + + .users-table { + width: 100%; + border-collapse: collapse; + + thead { + background: #f9fafb; + + tr { + border-bottom: 1px solid #e5e7eb; + } + + th { + padding: 0.875rem 1rem; + text-align: left; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: #6b7280; + letter-spacing: 0.025em; + + .material-icons { + font-size: 16px; + vertical-align: middle; + margin-right: 0.375rem; + opacity: 0.7; + } + + &.user-column { + width: 40%; + } + + &.status-column { + width: 25%; + } + + &.updated-column { + width: 20%; + } + + &.actions-column { + width: 15%; + text-align: right; + } + } + } + + tbody { + .user-row { + border-bottom: 1px solid #f3f4f6; + transition: background 0.15s ease; + + &:hover { + background: #fafbfc; + } + + &:last-child { + border-bottom: none; + } + } + + td { + padding: 1rem; + font-size: 0.875rem; + color: #374151; + + &.user-cell { + .user-info { + display: flex; + align-items: center; + gap: 0.75rem; + + .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid #e5e7eb; + } + + .user-avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + + .material-icons { + color: #9ca3af; + font-size: 20px; + } + } + + .user-details { + .user-name { + display: block; + font-weight: 600; + color: #111827; + text-decoration: none; + margin-bottom: 0.125rem; + + &:hover { + color: #2196F3; + } + } + + .user-meta { + font-size: 0.75rem; + color: #9ca3af; + } + } + } + } + + &.status-cell { + .moderation-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + + .material-icons { + font-size: 14px; + } + + &.approved { + background: #d1fae5; + color: #065f46; + + .material-icons { + color: #10b981; + } + } + + &.blocked { + background: #fee2e2; + color: #991b1b; + + .material-icons { + color: #ef4444; + } + } + + &.pending_moderation { + background: #fef3c7; + color: #92400e; + + .material-icons { + color: #f59e0b; + } + } + } + } + + &.updated-cell { + color: #6b7280; + } + + &.actions-cell { + text-align: right; + + .action-button { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid #e5e7eb; + background: white; + border-radius: 6px; + font-size: 0.75rem; + color: #6b7280; + text-decoration: none; + transition: all 0.15s ease; + + .material-icons { + font-size: 16px; + } + + &:hover { + border-color: #2196F3; + color: #2196F3; + background: #eff6ff; + } + } + } + } + } + } +} + +.no-results { + background: white; + border-radius: 8px; + border: 1px solid #e5e7eb; + padding: 3rem 2rem; + text-align: center; + + .no-results-icon { + margin-bottom: 1rem; + + .material-icons { + font-size: 64px; + color: #d1d5db; + } + } + + h3 { + margin: 0 0 0.5rem; + font-size: 1.25rem; + font-weight: 600; + color: #374151; + } + + p { + margin: 0 0 1.5rem; + color: #6b7280; + } + + .clear-search-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #374151; + text-decoration: none; + font-size: 0.875rem; + transition: all 0.15s ease; + + .material-icons { + font-size: 18px; + } + + &:hover { + background: #e5e7eb; + border-color: #d1d5db; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/navbar.scss b/app/assets/stylesheets/navbar.scss index 7fbc03ff8..7ac04b8a2 100644 --- a/app/assets/stylesheets/navbar.scss +++ b/app/assets/stylesheets/navbar.scss @@ -6,7 +6,7 @@ body.has-fixed-sidenav { padding-left: 300px !important; - nav.navbar.logged-in { + .notebook--sidebar.navbar.logged-in { width: calc(100% - 300px); } } @@ -18,12 +18,6 @@ } } -@media only screen and (min-width: 601px) { - nav, nav .nav-wrapper i, nav a.sidenav-trigger, nav a.sidenav-trigger i { - height: 56px; - line-height: 56px; - } -} nav.navbar { z-index: 10; diff --git a/app/assets/stylesheets/private-message-compose.scss b/app/assets/stylesheets/private-message-compose.scss new file mode 100644 index 000000000..6402b2706 --- /dev/null +++ b/app/assets/stylesheets/private-message-compose.scss @@ -0,0 +1,560 @@ +// Private Message Compose Styling +// Clean, professional email composition interface + +// Page background +#thredded--new-private-topic { + background: linear-gradient(180deg, #f9fafb 0%, #f3f4f6 100%); + min-height: calc(100vh - 200px); +} + +// Main compose container +.compose-container { + max-width: 800px; + margin: 2.5rem auto 1.5rem; + padding: 0 1.5rem; +} + +// Compose wrapper for card effect +.compose-wrapper { + background: white; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.1); + overflow: hidden; + margin-bottom: 2rem; +} + +// Compose header +.compose-header { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + padding: 1.25rem 1.5rem; + border-bottom: none; + + h1 { + font-size: 1.5rem; + font-weight: 600; + color: white; + margin: 0; + display: flex; + align-items: center; + + .material-icons { + font-size: 1.625rem; + margin-right: 0.625rem; + color: white; + opacity: 0.9; + } + } + + .compose-subtitle { + margin-top: 0.25rem; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.85); + margin-left: 2.25rem; + } +} + +// Compose form +.thredded--new-private-topic-form { + background: white; + border: none; + border-radius: 0; + padding: 0.5rem 0 0; + + .thredded--form-list { + margin: 0; + padding: 0; + list-style: none; + + li, + >div { + padding: 1rem 1.5rem; + border-bottom: 1px solid #f3f4f6; + margin: 0; + + &:last-child { + border-bottom: none; + background: #f9fafb; + padding: 1rem 1.5rem; + margin-top: 0; + } + } + + // Field labels + label { + display: block; + font-size: 0.875rem; + font-weight: 600; + color: #1f2937; + margin-bottom: 0.375rem; + + &::after { + content: ':'; + margin-left: 0.125rem; + } + } + + // Title field + li.title { + input[type="text"] { + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 1rem; + border: 1px solid #d1d5db; + border-radius: 6px; + transition: all 0.15s ease; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + } + + &::placeholder { + color: #9ca3af; + } + } + } + + // Recipients field + textarea[data-thredded-users-select] { + width: 100%; + min-height: 44px; + max-height: 120px; + padding: 0.625rem 1rem; + font-size: 0.9375rem; + border: 1px solid #d1d5db; + border-radius: 6px; + resize: vertical; + transition: all 0.15s ease; + font-family: inherit; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + min-height: 60px; + } + + &::placeholder { + color: #9ca3af; + } + } + + // Content field - target the textarea with ID + textarea#private_topic_content, + textarea#private_post_content, + textarea[name*="[content]"] { + width: 100%; + min-height: 180px; + padding: 1rem 1.125rem; + font-size: 1rem; + border: 1px solid #d1d5db; + border-radius: 8px; + resize: vertical; + transition: all 0.15s ease; + font-family: inherit; + line-height: 1.6; + background: #fafafa; + + &:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + min-height: 200px; + background: white; + } + + &::placeholder { + color: #9ca3af; + } + } + } +} + +// Recipients helper text +.recipients-helper { + margin-top: 0.375rem; + font-size: 0.75rem; + color: #6b7280; + + .material-icons { + font-size: 0.875rem; + vertical-align: middle; + margin-right: 0.25rem; + } +} + +// Selected users display +.selected-users { + margin-top: 0.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + + .user-tag { + display: inline-flex; + align-items: center; + padding: 0.375rem 0.625rem; + background: #eff6ff; + border: 1px solid #3b82f6; + border-radius: 16px; + font-size: 0.8125rem; + color: #1e40af; + + .remove-user { + margin-left: 0.375rem; + cursor: pointer; + color: #3b82f6; + font-weight: bold; + + &:hover { + color: #1e40af; + } + } + } +} + +// Form actions +.compose-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + + .left-actions { + display: flex; + gap: 0.5rem; + } + + .right-actions { + display: flex; + gap: 0.5rem; + } +} + +// Submit button +.thredded--form--submit { + display: inline-flex; + align-items: center; + padding: 0.75rem 1.75rem; + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: none; + border-radius: 6px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2); + + &:hover:not(:disabled) { + background: #2563eb; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .material-icons { + font-size: 1.125rem; + margin-right: 0.5rem; + } +} + +// Cancel/back button +.btn-cancel { + display: inline-flex; + align-items: center; + padding: 0.625rem 1.25rem; + background: white; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #f9fafb; + border-color: #d1d5db; + color: #374151; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(107, 114, 128, 0.1); + } +} + +// Preview button +.btn-preview { + display: inline-flex; + align-items: center; + padding: 0.625rem 1.25rem; + background: white; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + + .material-icons { + font-size: 1rem; + margin-right: 0.375rem; + } + + &:hover { + background: #f9fafb; + border-color: #d1d5db; + } + + &.active { + background: #eff6ff; + border-color: #3b82f6; + color: #2563eb; + } +} + +// Preview area +.thredded--preview-area { + margin-top: 1rem; + padding: 1rem; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 6px; + min-height: 100px; + + .thredded--preview-area--title { + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.025em; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid #e5e7eb; + } + + .thredded--preview-area--post { + color: #374151; + line-height: 1.6; + + &:empty::before { + content: 'Message preview will appear here...'; + color: #9ca3af; + font-style: italic; + } + } +} + +// Field errors +.thredded--form-field-errors { + margin-top: 0.375rem; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li { + padding: 0.375rem 0.625rem !important; + background: #fef2f2; + border: 1px solid #fecaca !important; + border-radius: 4px; + color: #dc2626; + font-size: 0.8125rem; + margin-top: 0.25rem; + } +} + +// Autocomplete dropdown +.textcomplete-dropdown { + background: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + margin-top: 0.25rem; + max-height: 200px; + overflow-y: auto; + + li { + padding: 0.5rem 0.75rem !important; + border-bottom: 1px solid #f3f4f6 !important; + cursor: pointer; + transition: background 0.15s ease; + + &:hover, + &.active { + background: #f3f4f6; + } + + &:last-child { + border-bottom: none !important; + } + } +} + +// Dark mode support +.dark { + #thredded--new-private-topic { + background: linear-gradient(180deg, #111827 0%, #0f172a 100%); + } + + .compose-wrapper { + background: #1f2937; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.4); + } + + .compose-header { + background: linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%); + border-color: #374151; + + h1 { + color: #f3f4f6; + + .material-icons { + color: #60a5fa; + } + } + + .compose-subtitle { + color: #9ca3af; + } + } + + .thredded--new-private-topic-form { + background: #1f2937; + border-color: #374151; + + .thredded--form-list { + + li, + >div { + border-bottom-color: #374151; + + &:last-child { + background: #111827; + } + } + + label { + color: #d1d5db; + } + + input[type="text"], + textarea { + background: #111827; + border-color: #374151; + color: #f3f4f6; + + &:focus { + border-color: #3b82f6; + } + + &::placeholder { + color: #6b7280; + } + } + + // Specific override for the content field to beat the ID selector specificity + textarea#private_topic_content, + textarea#private_post_content, + textarea[name*="[content]"] { + background: #111827; + color: #f3f4f6; + + &:focus { + background: #1f2937; + border-color: #3b82f6; + } + } + } + } + + .recipients-helper { + color: #9ca3af; + } + + .selected-users { + .user-tag { + background: #1e293b; + border-color: #3b82f6; + color: #60a5fa; + } + } + + .btn-cancel { + background: #1f2937; + border-color: #374151; + color: #9ca3af; + + &:hover { + background: #374151; + } + } + + .thredded--preview-area { + background: #111827; + border-color: #374151; + + .thredded--preview-area--title { + color: #9ca3af; + border-bottom-color: #374151; + } + + .thredded--preview-area--post { + color: #d1d5db; + + &:empty::before { + color: #6b7280; + } + } + } +} + +// Mobile responsiveness +@media (max-width: 768px) { + .compose-container { + margin: 1rem auto; + } + + .compose-header { + padding: 1rem; + + h1 { + font-size: 1.25rem; + } + } + + .thredded--new-private-topic-form { + .thredded--form-list li { + padding: 1rem; + } + } + + .compose-actions { + flex-direction: column; + + .left-actions, + .right-actions { + width: 100%; + + button { + flex: 1; + } + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/private-messages.scss b/app/assets/stylesheets/private-messages.scss new file mode 100644 index 000000000..c9c304a22 --- /dev/null +++ b/app/assets/stylesheets/private-messages.scss @@ -0,0 +1,544 @@ +// Private Messages / Inbox Styling +// Modern, clean inbox design for private conversations + +// Main container +.thredded--private-topics { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; +} + +// Page header +.private-messages-header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid #e5e7eb; + + .header-content { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + } + + h1 { + font-size: 1.875rem; + font-weight: 700; + color: #111827; + margin: 0; + display: flex; + align-items: center; + + .material-icons { + font-size: 2rem; + margin-right: 0.75rem; + color: #3b82f6; + } + } + + .header-actions { + display: flex; + gap: 0.5rem; + + .btn-compose { + display: inline-flex; + align-items: center; + padding: 0.625rem 1.25rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + transition: all 0.15s ease; + + &:hover { + background: #2563eb; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2); + } + + .material-icons { + font-size: 1.125rem; + margin-right: 0.5rem; + } + } + } +} + +// Inbox stats +.inbox-stats { + display: flex; + gap: 2rem; + margin-top: 0.75rem; + font-size: 0.875rem; + color: #6b7280; + + .stat { + display: flex; + align-items: center; + gap: 0.5rem; + + .material-icons { + font-size: 1rem; + opacity: 0.7; + } + + strong { + color: #374151; + font-weight: 600; + } + } +} + +// Messages list container +.messages-list { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +// Individual message/conversation row +.thredded--topics--topic { + display: flex; + align-items: center; + padding: 1rem 1.25rem; + border-bottom: 1px solid #f3f4f6; + transition: all 0.15s ease; + position: relative; + + &:hover { + background: #f9fafb; + } + + &:last-child { + border-bottom: none; + } + + // Unread indicator + &.thredded--topic-unread { + background: #fefce8; + border-left: 3px solid #facc15; + + .thredded--topics--title { + font-weight: 600; + color: #111827; + } + + &::before { + content: ''; + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + background: #3b82f6; + border-radius: 50%; + } + } +} + +// Participants avatars +.message-participants { + flex-shrink: 0; + margin-right: 1rem; + display: flex; + align-items: center; + + .participant-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid white; + margin-right: -8px; + position: relative; + background: #f3f4f6; + display: flex; + align-items: center; + justify-content: center; + + &:first-child { + z-index: 3; + } + + &:nth-child(2) { + z-index: 2; + } + + &:nth-child(3) { + z-index: 1; + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + } + + // Initials for users without avatars + .initials { + font-size: 0.75rem; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + } + } + + // More participants indicator + .more-participants { + margin-left: 0.25rem; + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; + } +} + +// Message content area +.message-content { + flex: 1; + min-width: 0; + + .thredded--topics--title { + margin: 0 0 0.25rem 0; + font-size: 0.9375rem; + line-height: 1.4; + + a { + color: #374151; + text-decoration: none; + + &:hover { + color: #3b82f6; + } + } + } + + // Message preview/excerpt + .message-preview { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 500px; + } +} + +// Posts count badge +.thredded--topics--posts-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + background: #f3f4f6; + color: #6b7280; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + margin-right: 1rem; + flex-shrink: 0; + + .thredded--topic-unread & { + background: #3b82f6; + color: white; + } +} + +// Timestamp and metadata +.thredded--topics--updated-by { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 0.75rem; + color: #9ca3af; + font-style: normal; + min-width: 100px; + + time { + font-weight: 500; + } + + .thredded--topics--participants { + margin-top: 0.25rem; + display: flex; + gap: 0.25rem; + + a { + color: #6b7280; + text-decoration: none; + + &:hover { + color: #3b82f6; + text-decoration: underline; + } + } + } +} + +// Empty state +.thredded--empty { + text-align: center; + padding: 4rem 2rem; + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + + &::before { + content: ''; + display: block; + width: 80px; + height: 80px; + margin: 0 auto 1.5rem; + background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='1.5'%3E%3Cpath d='M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z'/%3E%3C/svg%3E") no-repeat center; + background-size: contain; + opacity: 0.5; + } + + .thredded--empty--title { + font-size: 1.125rem; + color: #374151; + margin-bottom: 0.75rem; + font-weight: 600; + } + + p { + color: #6b7280; + font-size: 0.875rem; + margin-bottom: 1.5rem; + } + + .thredded--button { + display: inline-flex; + align-items: center; + padding: 0.625rem 1.25rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + transition: all 0.15s ease; + + &:hover { + background: #2563eb; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.2); + } + + .material-icons { + font-size: 1.125rem; + margin-right: 0.5rem; + } + } +} + +// Mark all read button +.thredded--button-wide { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + margin-top: 1rem; + padding: 0.625rem 1.25rem; + background: white; + color: #6b7280; + border: 1px solid #e5e7eb; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + transition: all 0.15s ease; + + &:hover { + background: #f9fafb; + border-color: #d1d5db; + color: #374151; + } +} + +// Pagination +.thredded--pagination-bottom { + margin-top: 2rem; + + .pagination { + display: flex; + justify-content: center; + gap: 0.5rem; + + a, span { + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + border-radius: 4px; + text-decoration: none; + transition: all 0.15s ease; + } + + a { + background: white; + border: 1px solid #e5e7eb; + color: #374151; + + &:hover { + background: #f9fafb; + border-color: #d1d5db; + } + } + + .current { + background: #3b82f6; + color: white; + border: 1px solid #3b82f6; + } + } +} + +// Dark mode support +.dark { + .private-messages-header { + border-bottom-color: #374151; + + h1 { + color: #f3f4f6; + + .material-icons { + color: #60a5fa; + } + } + } + + .inbox-stats { + color: #9ca3af; + + .stat strong { + color: #d1d5db; + } + } + + .messages-list { + background: #1f2937; + border-color: #374151; + } + + .thredded--topics--topic { + border-bottom-color: #374151; + + &:hover { + background: #111827; + } + + &.thredded--topic-unread { + background: #1e293b; + border-left-color: #facc15; + } + } + + .message-content { + .thredded--topics--title a { + color: #e5e7eb; + + &:hover { + color: #60a5fa; + } + } + + .message-preview { + color: #9ca3af; + } + } + + .thredded--topics--posts-count { + background: #374151; + color: #d1d5db; + + .thredded--topic-unread & { + background: #3b82f6; + color: white; + } + } + + .thredded--empty { + background: #1f2937; + border-color: #374151; + + .thredded--empty--title { + color: #e5e7eb; + } + + p { + color: #9ca3af; + } + } + + .thredded--pagination-bottom { + .pagination { + a { + background: #374151; + border-color: #4b5563; + color: #e5e7eb; + + &:hover { + background: #4b5563; + border-color: #6b7280; + } + } + + .current { + background: #3b82f6; + border-color: #3b82f6; + } + } + } +} + +// Mobile responsiveness +@media (max-width: 768px) { + .private-messages-header { + .header-content { + flex-direction: column; + align-items: flex-start; + } + + h1 { + font-size: 1.5rem; + } + } + + .inbox-stats { + flex-wrap: wrap; + gap: 1rem; + } + + .thredded--topics--topic { + padding: 0.875rem 1rem; + } + + .message-participants { + margin-right: 0.75rem; + + .participant-avatar { + width: 32px; + height: 32px; + } + } + + .message-content { + .message-preview { + max-width: 100%; + } + } + + .thredded--topics--updated-by { + min-width: auto; + font-size: 0.6875rem; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/smoothscrolling.scss b/app/assets/stylesheets/smoothscrolling.scss new file mode 100644 index 000000000..2d0604790 --- /dev/null +++ b/app/assets/stylesheets/smoothscrolling.scss @@ -0,0 +1,7 @@ +.anchor-offset { + display: block; + height: 136px; /* Height of floating navbar */ + margin-top: -136px; /* Same height as above */ + visibility: hidden; + pointer-events: none; +} \ No newline at end of file diff --git a/app/assets/stylesheets/styleguide.scss b/app/assets/stylesheets/styleguide.scss new file mode 100644 index 000000000..270c5f180 --- /dev/null +++ b/app/assets/stylesheets/styleguide.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Styleguide controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: https://sass-lang.com/ diff --git a/app/assets/stylesheets/tailwind-custom.scss b/app/assets/stylesheets/tailwind-custom.scss new file mode 100644 index 000000000..69f8599e8 --- /dev/null +++ b/app/assets/stylesheets/tailwind-custom.scss @@ -0,0 +1,297 @@ +.mega-menu { + display: none; + left: 0; + position: absolute; + text-align: left; + width: 100%; +} + +.hoverable { + position: static; +} + +.hoverable>a:after { + content: "\25BC"; + font-size: 10px; + padding-left: 6px; + position: relative; + top: -1px; +} + +.hoverable:hover .mega-menu { + display: block; +} + +.toggleable>label:after { + content: "\25BC"; + font-size: 10px; + padding-left: 6px; + position: relative; + top: -1px; +} + +.toggle-input { + display: none; +} + +.toggle-input:not(checked)~.mega-menu { + display: none; +} + +.toggle-input:checked~.mega-menu { + display: block; +} + +.toggle-input:checked+label { + color: white; + background: #2c5282; + /*@apply bg-blue-800 */ +} + +.toggle-input:checked~label:after { + content: "\25B2"; + font-size: 10px; + padding-left: 6px; + position: relative; + top: -1px; +} + +/* Casino icon rotation animation */ +.casino-icon-rotate { + transition: transform 200ms ease; +} + +.group:hover .casino-icon-rotate { + transform: rotate(90deg); +} + +/* Casino button click animations */ +.casino-button-clicking { + animation: quickPress 0.2s ease-out; + position: relative; + overflow: visible !important; +} + +.casino-button-clicking .casino-icon-rotate { + animation: quickSpin 0.3s cubic-bezier(0.4, 0, 0.2, 1), + slowSpin 2s linear 0.3s infinite; +} + +/* Ripple effect from click point */ +.ripple { + position: absolute; + border-radius: 50%; + background: rgba(255, 255, 255, 0.5); + transform: scale(0); + animation: rippleExpand 0.6s ease-out; + pointer-events: none; + z-index: 0; +} + +@keyframes quickPress { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(0.96); + } + + 100% { + transform: scale(1); + } +} + +@keyframes quickSpin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes slowSpin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes rippleExpand { + to { + transform: scale(4); + opacity: 0; + } +} + +/* Custom Sidebar Dark Mode Support */ + + +.sidebar-item-hover:hover { + background-color: #eff6ff; + /* blue-50 */ +} + +.dark .sidebar-item-hover:hover { + background-color: rgba(59, 130, 246, 0.2); // blue-500 with opacity 0.2 +} + +.sidebar-item-hover-purple:hover { + background-color: #faf5ff; + /* purple-50 */ +} + +.dark .sidebar-item-hover-purple:hover { + background-color: rgba(168, 85, 247, 0.2); // purple-500 with opacity 0.2 +} + +.sidebar-item-active { + background-color: #eff6ff; + /* blue-50 */ + color: #1e40af; + /* blue-800 */ +} + +.dark .sidebar-item-active { + background-color: rgba(30, 58, 138, 0.5); // blue-900 with opacity + color: #bfdbfe; + /* blue-200 */ +} + +/* jQuery UI Autocomplete Dropdown Styling */ +.ui-autocomplete { + position: absolute; + z-index: 9999; + background-color: #ffffff; + border: 1px solid #e5e7eb; + /* gray-200 */ + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + max-height: 300px; + overflow-y: auto; + list-style: none; + padding: 0.25rem 0; + margin: 0; +} + +.dark .ui-autocomplete { + background-color: #1f2937; + /* gray-800 */ + border-color: #374151; + /* gray-700 */ +} + +.ui-autocomplete .ui-menu-item { + padding: 0; + margin: 0; +} + +.ui-autocomplete .ui-menu-item-wrapper { + display: block; + padding: 0.5rem 1rem; + cursor: pointer; + color: #111827; + /* gray-900 */ + transition: background-color 150ms ease; +} + +.dark .ui-autocomplete .ui-menu-item-wrapper { + color: #f3f4f6; + /* gray-100 */ +} + +.ui-autocomplete .ui-menu-item-wrapper:hover, +.ui-autocomplete .ui-menu-item-wrapper.ui-state-active { + background-color: #f3f4f6; + /* gray-100 */ +} + +.dark .ui-autocomplete .ui-menu-item-wrapper:hover, +.dark .ui-autocomplete .ui-menu-item-wrapper.ui-state-active { + background-color: #374151; + /* gray-700 */ +} + +/* Autosave visual feedback styles */ +.js-autosave.border-yellow-400 { + border-color: #fbbf24 !important; + box-shadow: 0 0 0 1px #fbbf24 !important; +} + +.js-autosave.border-green-400 { + border-color: #34d399 !important; + box-shadow: 0 0 0 1px #34d399 !important; +} + +.js-autosave.border-red-400 { + border-color: #f87171 !important; + box-shadow: 0 0 0 1px #f87171 !important; +} + +.js-autosave:focus.border-yellow-400 { + border-color: #fbbf24 !important; + box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.2) !important; +} + +.js-autosave:focus.border-green-400 { + border-color: #34d399 !important; + box-shadow: 0 0 0 2px rgba(52, 211, 153, 0.2) !important; +} + +.js-autosave:focus.border-red-400 { + border-color: #f87171 !important; + box-shadow: 0 0 0 2px rgba(248, 113, 113, 0.2) !important; +} + +/* Hide scrollbar for collapsed sidebar icon ribbon */ +.thin-scrollbar { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + overscroll-behavior: contain; /* Prevent scroll chaining to page body */ +} + +.thin-scrollbar::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + +/* Global sidebar tooltip element (positioned via JS) */ +#sidebar-tooltip { + position: fixed; + padding: 8px 12px; + background-color: rgba(0, 0, 0, 0.9); + color: white; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + border-radius: 6px; + pointer-events: none; + z-index: 99999; + opacity: 0; + transition: opacity 0.15s ease; +} + +#sidebar-tooltip.visible { + opacity: 1; +} + +/* Sidebar layout margin/padding utilities - defined here to avoid Tailwind purge issues */ +@media (min-width: 768px) { + .md\:ml-56 { + margin-left: 14rem; /* 224px */ + } + .md\:ml-16 { + margin-left: 4rem; /* 64px */ + } + .md\:pl-56 { + padding-left: 14rem; /* 224px */ + } + .md\:pl-16 { + padding-left: 4rem; /* 64px */ + } +} + diff --git a/app/assets/stylesheets/tailwind.css b/app/assets/stylesheets/tailwind.css new file mode 100644 index 000000000..03d3ed932 --- /dev/null +++ b/app/assets/stylesheets/tailwind.css @@ -0,0 +1,25 @@ +/* Tailwind CSS imports */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Global word-breaking for user-generated content */ +@layer base { + dd, .prose, .markdownable, .formatted-text, .space-y-4, textarea, .content-field { + overflow-wrap: anywhere; + word-wrap: break-word; + word-break: break-word; + } + + /* Ensure user-generated links are clearly visible and clickable */ + .prose a, .markdownable a, .formatted-text a, .space-y-4 a, dd a { + color: #2196F3; + text-decoration: underline; + word-break: break-word; + } + + .prose a:hover, .markdownable a:hover, .formatted-text a:hover, .space-y-4 a:hover, dd a:hover { + color: #0d47a1; + text-decoration: none; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/thredded-overrides.scss b/app/assets/stylesheets/thredded-overrides.scss index 928cf365d..c07a374d6 100644 --- a/app/assets/stylesheets/thredded-overrides.scss +++ b/app/assets/stylesheets/thredded-overrides.scss @@ -1,232 +1,282 @@ -@import "thredded"; +// Thredded Forum Overrides +// This file contains styling overrides for the Thredded forum gem + +// Hide the SVG definition container completely +// These SVG icons are defined by Thredded but we use Material Icons instead +.thredded--svg-definitions { + display: none !important; + visibility: hidden !important; + position: absolute !important; + width: 0 !important; + height: 0 !important; + overflow: hidden !important; +} + +// Fallback: If any SVGs somehow still display, keep them small +// This ensures no giant icons break the layout +svg { + max-width: 1.5rem; + max-height: 1.5rem; +} + +// Specific IDs for thredded icons (belt and suspenders approach) +#thredded-follow-icon, +#thredded-lock-icon, +#thredded-unfollow-icon { + display: none !important; +} -#thredded--container { - .thredded--navigation--search { +// Dark Mode Overrides for Forum Topics +.dark { + .thredded-topic-header { + background-color: #111827 !important; // gray-900 + border-color: #1f2937 !important; // gray-800 - #q /* search input */ { - padding-left: 16px; - margin-top: 4.5em; + h1 { + color: #ffffff !important; } - @media only screen and (min-width: 600px) { - #q /* search input */ { - height: 17px; - margin-top: 0; - } + .text-stone-500 { + color: rgba(255, 255, 255, 0.6) !important; + } + + .text-stone-600 { + color: #d1d5db !important; // gray-300 } - } - .thredded--navigation-breadcrumbs { - margin-top: 0.4em; - padding-left: 0.1em; - overflow: hidden; - min-height: 3em; - max-height: 60px; + .text-stone-900 { + color: #ffffff !important; + } - width: 50%; + .bg-stone-50 { + background-color: rgba(255, 255, 255, 0.05) !important; + border-color: rgba(255, 255, 255, 0.1) !important; - li { - display: inline !important; - float: left; - padding-right: 4px; - margin-right: 8px; + &:hover { + background-color: rgba(255, 255, 255, 0.1) !important; + } } - li a { - padding: 0; - line-height: 2rem; + .bg-stone-100 { + background-color: rgba(255, 255, 255, 0.05) !important; + color: rgba(255, 255, 255, 0.8) !important; + border-color: rgba(255, 255, 255, 0.1) !important; + + &:hover { + background-color: rgba(255, 255, 255, 0.1) !important; + } } } - .thredded--scoped-navigation { - left: 1rem; - top: 1rem; - } + .thredded-post-card { + .bg-white { + background-color: #111827 !important; // gray-900 + border-color: #1f2937 !important; // gray-800 + } - .thredded--scoped-navigation li a { - padding: 0; - line-height: 2rem; - float: left; - } + .border-stone-200 { + border-color: #1f2937 !important; // gray-800 + } - .thredded--user-navigation { - height: 2rem; - margin: 1rem; - border-bottom: 0; + .border-stone-300 { + border-color: #374151 !important; // gray-700 + } - @media only screen and (max-width: 600px) { - .thredded--user-navigation--item { - padding-top: 0.5rem; - padding-right: 0.5rem; - } + .border-stone-100 { + border-color: #1f2937 !important; // gray-800 } - .thredded--user-navigation--item a { - padding: 8px 4px; - line-height: 2rem; + .bg-white\/50 { + background-color: rgba(255, 255, 255, 0.05) !important; } - } - @media only screen and (max-width: 600px) { - .thredded--user-navigation { - margin: 0; + .text-stone-900 { + color: #f3f4f6 !important; // gray-100 } - } - .thredded--post--user { - color: black; + .text-stone-500 { + color: #9ca3af !important; // gray-400 + } - a { - color: #347a36; + .text-stone-800 { + color: #e5e7eb !important; // gray-200 } - } - .thredded--currently-online { - right: 100px; - max-height: 80%; - overflow-y: hidden; - overflow-x: hidden; - border: 1px solid lightgrey; - } + .bg-stone-200 { + background-color: #374151 !important; // gray-700 + } - .thredded--currently-online.thredded--is-expanded { - overflow-y: auto; - overflow-x: hidden; - } + .bg-stone-300 { + background-color: #4b5563 !important; // gray-600 + } - .thredded--new-topic-form { - background: white; - padding: 10px; - padding-bottom: 0; - border: 1px solid lightgrey; + .bg-stone-50 { + background-color: rgba(0, 0, 0, 0.2) !important; + border-color: #1f2937 !important; // gray-800 + } - margin-bottom: 20px; - } + .prose-stone { + color: #e5e7eb !important; // gray-200 - .thredded--topics--topic { - background: white; - padding: 4px 26px; - border: 1px solid lightgrey; + strong { + color: #fff !important; + } - margin-bottom: 0.7rem; - } + a { + color: #60a5fa !important; + } - .thredded--topics--posts-count { - left: -1rem; - top: 2px; - } + blockquote { + color: #d1d5db !important; + border-left-color: #374151 !important; + } - .thredded--topics--follow-icon { - right: 0.2rem; - top: 6px; - } + code { + color: #f3f4f6 !important; + } - .thredded--pagination { - background: white; - padding: 0 0 10px 0; + h1, + h2, + h3, + h4 { + color: #f3f4f6 !important; + } + } } - .thredded--topic .thredded--post { - background: white; - padding: 10px; + // Forums Index Header + .thredded-forums-header { + background-color: #111827 !important; // gray-900 + border-color: #1f2937 !important; // gray-800 - margin-bottom: 20px; - border-bottom: 1px solid lightgrey; - } + h1 { + color: #ffffff !important; + } - @media (min-width: 47.12501rem) { - .thredded--post--avatar { - top: 0; + .text-stone-500 { + color: rgba(255, 255, 255, 0.6) !important; } } - .thredded--messageboard { - background: white; - border-bottom: 1px solid lightgrey; - margin: 2px; - } -} + // Messageboard Cards + .thredded-messageboard-card { + background-color: #1f2937 !important; // gray-800 + border-color: #1f2937 !important; // gray-800 (was gray-700, made darker to blend) -.thredded--main-header { - nav { - background: white; + &:hover { + background-color: #111827 !important; // gray-900 + border-color: #374151 !important; // gray-700 + } - margin-bottom: 4rem; - line-height: 3rem; - } + h3 { + color: #f3f4f6 !important; // gray-100 + } + + .text-gray-900 { + color: #f3f4f6 !important; + } + + .text-gray-500, + .text-gray-600, + .text-gray-700 { + color: #9ca3af !important; // gray-400 + } + .messageboard-stats { + color: #9ca3af !important; + } - @media only screen and (min-width: 600px) { - nav { - height: 3rem; + .messageboard-footer { + border-top-color: #374151 !important; // gray-700 + background-color: rgba(0, 0, 0, 0.2) !important; // Darken footer bg } } } -.thredded--topic-header { - .thredded--topic-header--title { - margin-bottom: 0.4em; +// ============================================= +// User Autocomplete Dropdown +// (Private messages participant selector, @mentions) +// ============================================= + +ul.thredded--textcomplete-dropdown { + position: absolute; + z-index: 100; + background: white; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + list-style: none; + margin: 0.25rem 0 0 0; + padding: 0.25rem; + max-height: 300px; + overflow-y: auto; + min-width: 250px; + + li.textcomplete-item { + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover, + &.active { + background: #f3f4f6; + } + + a { + text-decoration: none; + color: inherit; + display: block; + } } } -.thredded--main-container { - // The padding and max-width are handled by the app's container. - min-width: 80%; - padding: 0; - @include thredded-media-tablet-and-up { - padding: 0; +.thredded--textcomplete-user-result { + display: flex; + align-items: center; + gap: 0.75rem; + + &__avatar { + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + object-fit: cover; + flex-shrink: 0; } -} -.thredded--form { - input[type="checkbox"] { - position: relative !important; - opacity: 1 !important; - pointer-events: inherit !important; + &__name { + font-weight: 500; + color: #111827; } -} -.thredded--messageboard--byline { - i.material-icons { - font-size: 0.9em; - color: grey; + &__display_name { + color: #6b7280; + font-size: 0.875rem; + margin-left: 0.25rem; } } -.thredded--post--content { - padding-top: 0.6em !important; - - ul { - list-style-type: inherit !important; - padding-left: 40px !important; +// Dark mode for autocomplete dropdown +.dark { + ul.thredded--textcomplete-dropdown { + background: #1f2937; + border-color: #374151; - li { - list-style-type: disc !important; + li.textcomplete-item { + &:hover, + &.active { + background: #374151; + } } } - ol { - list-style-type: inherit !important; - padding-left: 40px !important; - li { - list-style-type: decimal !important; + .thredded--textcomplete-user-result { + &__name { + color: #f3f4f6; } - } -} - -.muted-thredded-post { - color: #616161; - background-color: #f5f5f5 !important; - border-bottom: 0 !important; -} -.thredded--post { - .card-content { - padding: 0 !important; - padding-top: 5px !important; + &__display_name { + color: #9ca3af; + } } -} -.thredded--topic-delete--wrapper { - margin-top: 0.5rem !important; - padding-top: 1rem !important; -} +} \ No newline at end of file diff --git a/app/assets/stylesheets/thredded-overrides.scss.bak b/app/assets/stylesheets/thredded-overrides.scss.bak new file mode 100644 index 000000000..498c1b998 --- /dev/null +++ b/app/assets/stylesheets/thredded-overrides.scss.bak @@ -0,0 +1,31 @@ +@import "thredded"; + +.thredded--topic-header--follow-info--follow { + position: static !important; +} + +.thredded--topic-header--follow-info--unfollow { + position: static !important; +} + +#thredded--container { + margin-left: 32px !important; +} + +.thredded--container { + margin: 0; + margin-left: 3em !important; +} + + +.thredded--main-container { + max-width: 100%; +} + +.thredded--post--content { + font-size: 12pt; +} + +.thredded--post { + margin-bottom: 1em !important; +} \ No newline at end of file diff --git a/app/assets/stylesheets/timeline-editor.scss b/app/assets/stylesheets/timeline-editor.scss deleted file mode 100644 index f5e91e1ff..000000000 --- a/app/assets/stylesheets/timeline-editor.scss +++ /dev/null @@ -1,7 +0,0 @@ -.timeline-event-template { - display: none; -} - -.loading-indicator { - display: none; -} \ No newline at end of file diff --git a/app/assets/stylesheets/timeline.scss b/app/assets/stylesheets/timeline.scss deleted file mode 100644 index 24d1ab286..000000000 --- a/app/assets/stylesheets/timeline.scss +++ /dev/null @@ -1,449 +0,0 @@ - - -/* -------------------------------- - -Modules - reusable parts of our design - --------------------------------- */ -.cd-container { - /* this class is used to give a max-width to the element it is applied to, and center it horizontally when it reaches that max-width */ - width: 90%; - max-width: 1170px; - margin: 0 auto; -} -.cd-container::after { - /* clearfix */ - content: ''; - display: table; - clear: both; -} - -/* -------------------------------- - -Main components - --------------------------------- */ -.timeline-container { - header { - height: 200px; - line-height: 200px; - text-align: center; - background: #303e49; - } - header h1 { - color: white; - font-size: 18px; - font-size: 1.125rem; - } - @media only screen and (min-width: 1170px) { - header { - height: 300px; - line-height: 300px; - } - header h1 { - font-size: 24px; - font-size: 1.5rem; - } - } -} - -#cd-timeline { - position: relative; - padding: 2em 0; - margin-top: 2em; - margin-bottom: 2em; -} -#cd-timeline::before { - /* this is the vertical line */ - content: ''; - position: absolute; - top: 0; - left: 18px; - height: 100%; - width: 4px; - background: #d7e4ed; -} -@media only screen and (min-width: 1170px) { - #cd-timeline { - margin-top: 3em; - margin-bottom: 3em; - } - #cd-timeline::before { - left: 50%; - margin-left: -2px; - } -} - -.cd-timeline-block { - position: relative; - margin: 2em 0; -} -.cd-timeline-block:after { - content: ""; - display: table; - clear: both; -} -.cd-timeline-block:first-child { - margin-top: 0; -} -.cd-timeline-block:last-child { - margin-bottom: 0; -} -@media only screen and (min-width: 1170px) { - .cd-timeline-block { - margin: 4em 0; - } - .cd-timeline-block:first-child { - margin-top: 0; - } - .cd-timeline-block:last-child { - margin-bottom: 0; - } -} - -.cd-timeline-img { - position: absolute; - top: 0; - left: 0; - width: 40px; - height: 40px; - border-radius: 50%; - box-shadow: 0 0 0 4px white, inset 0 2px 0 rgba(0, 0, 0, 0.08), 0 3px 0 4px rgba(0, 0, 0, 0.05); -} -.cd-timeline-img i { - display: block; - width: 24px; - height: 24px; - position: relative; - left: 50%; - top: 50%; - margin-left: -12px; - margin-top: -12px; -} -.cd-timeline-img.cd-picture { - background: #75ce66; -} -.cd-timeline-img.cd-movie { - background: #c03b44; -} -.cd-timeline-img.cd-location { - background: #f0ca45; -} -@media only screen and (min-width: 1170px) { - .cd-timeline-img { - width: 60px; - height: 60px; - left: 50%; - margin-left: -30px; - /* Force Hardware Acceleration in WebKit */ - -webkit-transform: translateZ(0); - -webkit-backface-visibility: hidden; - } - .cssanimations .cd-timeline-img.is-hidden { - visibility: hidden; - } - .cssanimations .cd-timeline-img.bounce-in { - visibility: visible; - -webkit-animation: cd-bounce-1 0.6s; - -moz-animation: cd-bounce-1 0.6s; - animation: cd-bounce-1 0.6s; - } -} - -@-webkit-keyframes cd-bounce-1 { - 0% { - opacity: 0; - -webkit-transform: scale(0.5); - } - - 60% { - opacity: 1; - -webkit-transform: scale(1.2); - } - - 100% { - -webkit-transform: scale(1); - } -} -@-moz-keyframes cd-bounce-1 { - 0% { - opacity: 0; - -moz-transform: scale(0.5); - } - - 60% { - opacity: 1; - -moz-transform: scale(1.2); - } - - 100% { - -moz-transform: scale(1); - } -} -@keyframes cd-bounce-1 { - 0% { - opacity: 0; - -webkit-transform: scale(0.5); - -moz-transform: scale(0.5); - -ms-transform: scale(0.5); - -o-transform: scale(0.5); - transform: scale(0.5); - } - - 60% { - opacity: 1; - -webkit-transform: scale(1.2); - -moz-transform: scale(1.2); - -ms-transform: scale(1.2); - -o-transform: scale(1.2); - transform: scale(1.2); - } - - 100% { - -webkit-transform: scale(1); - -moz-transform: scale(1); - -ms-transform: scale(1); - -o-transform: scale(1); - transform: scale(1); - } -} -.cd-timeline-content { - position: relative; - margin-left: 60px; - background: white; - border-radius: 0.25em; - box-shadow: 0 3px 0 #d7e4ed; -} -.cd-timeline-content.card .card-content { - padding: 5px 10px; -} -.cd-timeline-content:after { - display: table; - clear: both; -} -.cd-timeline-content h5 { - color: #303e49; -} -.cd-timeline-content p, .cd-timeline-content .cd-read-more, .cd-timeline-content .cd-date { - font-size: 13px; - font-size: 0.8125rem; -} -.cd-timeline-content .cd-read-more, .cd-timeline-content .cd-date { - display: inline-block; -} -.cd-timeline-content p { - margin: 1em 0; - line-height: 1.6; -} -.cd-timeline-content .cd-read-more { - float: right; - padding: .8em 1em; - background: #acb7c0; - color: white; - border-radius: 0.25em; -} -.no-touch .cd-timeline-content .cd-read-more:hover { - background-color: #bac4cb; -} -.cd-timeline-content .cd-date { - float: left; - padding: .8em 0; - opacity: .7; -} -.cd-timeline-content::before { - content: ''; - position: absolute; - top: 16px; - right: 100%; - height: 0; - width: 0; - border: 7px solid transparent; - border-right: 7px solid white; -} -@media only screen and (min-width: 768px) { - .cd-timeline-content h5 { - font-size: 16px; - font-size: 1.25rem; - } - .cd-timeline-content p { - font-size: 16px; - font-size: 1rem; - } - .cd-timeline-content .cd-read-more, .cd-timeline-content .cd-date { - font-size: 14px; - font-size: 0.875rem; - } -} -@media only screen and (min-width: 1170px) { - .cd-timeline-content { - margin-left: 0; - width: 45%; - } - .cd-timeline-content::before { - top: 24px; - left: 100%; - border-color: transparent; - border-left-color: white; - } - .cd-timeline-content .cd-read-more { - float: left; - } - .cd-timeline-content .cd-date { - position: absolute; - width: 100%; - left: 122%; - top: 6px; - font-size: 16px; - font-size: 1rem; - } - .cd-timeline-block:nth-child(even) .cd-timeline-content { - float: right; - } - .cd-timeline-block:nth-child(even) .cd-timeline-content::before { - top: 24px; - left: auto; - right: 100%; - border-color: transparent; - border-right-color: white; - } - .cd-timeline-block:nth-child(even) .cd-timeline-content .cd-read-more { - float: right; - } - .cd-timeline-block:nth-child(even) .cd-timeline-content .cd-date { - left: auto; - right: 122%; - text-align: right; - } - .cssanimations .cd-timeline-content.is-hidden { - visibility: hidden; - } - .cssanimations .cd-timeline-content.bounce-in { - visibility: visible; - -webkit-animation: cd-bounce-2 0.6s; - -moz-animation: cd-bounce-2 0.6s; - animation: cd-bounce-2 0.6s; - } -} - -@media only screen and (min-width: 1170px) { - /* inverse bounce effect on even content blocks */ - .cssanimations .cd-timeline-block:nth-child(even) .cd-timeline-content.bounce-in { - -webkit-animation: cd-bounce-2-inverse 0.6s; - -moz-animation: cd-bounce-2-inverse 0.6s; - animation: cd-bounce-2-inverse 0.6s; - } -} -@-webkit-keyframes cd-bounce-2 { - 0% { - opacity: 0; - -webkit-transform: translateX(-100px); - } - - 60% { - opacity: 1; - -webkit-transform: translateX(20px); - } - - 100% { - -webkit-transform: translateX(0); - } -} -@-moz-keyframes cd-bounce-2 { - 0% { - opacity: 0; - -moz-transform: translateX(-100px); - } - - 60% { - opacity: 1; - -moz-transform: translateX(20px); - } - - 100% { - -moz-transform: translateX(0); - } -} -@keyframes cd-bounce-2 { - 0% { - opacity: 0; - -webkit-transform: translateX(-100px); - -moz-transform: translateX(-100px); - -ms-transform: translateX(-100px); - -o-transform: translateX(-100px); - transform: translateX(-100px); - } - - 60% { - opacity: 1; - -webkit-transform: translateX(20px); - -moz-transform: translateX(20px); - -ms-transform: translateX(20px); - -o-transform: translateX(20px); - transform: translateX(20px); - } - - 100% { - -webkit-transform: translateX(0); - -moz-transform: translateX(0); - -ms-transform: translateX(0); - -o-transform: translateX(0); - transform: translateX(0); - } -} -@-webkit-keyframes cd-bounce-2-inverse { - 0% { - opacity: 0; - -webkit-transform: translateX(100px); - } - - 60% { - opacity: 1; - -webkit-transform: translateX(-20px); - } - - 100% { - -webkit-transform: translateX(0); - } -} -@-moz-keyframes cd-bounce-2-inverse { - 0% { - opacity: 0; - -moz-transform: translateX(100px); - } - - 60% { - opacity: 1; - -moz-transform: translateX(-20px); - } - - 100% { - -moz-transform: translateX(0); - } -} -@keyframes cd-bounce-2-inverse { - 0% { - opacity: 0; - -webkit-transform: translateX(100px); - -moz-transform: translateX(100px); - -ms-transform: translateX(100px); - -o-transform: translateX(100px); - transform: translateX(100px); - } - - 60% { - opacity: 1; - -webkit-transform: translateX(-20px); - -moz-transform: translateX(-20px); - -ms-transform: translateX(-20px); - -o-transform: translateX(-20px); - transform: translateX(-20px); - } - - 100% { - -webkit-transform: translateX(0); - -moz-transform: translateX(0); - -ms-transform: translateX(0); - -o-transform: translateX(0); - transform: translateX(0); - } -} diff --git a/app/assets/stylesheets/timeline_core.scss b/app/assets/stylesheets/timeline_core.scss new file mode 100644 index 000000000..be674e377 --- /dev/null +++ b/app/assets/stylesheets/timeline_core.scss @@ -0,0 +1,929 @@ +/* ========================= + TIMELINE CORE STYLES + Shared styles for both edit and view modes + Consolidated from: timeline.scss + timeline_events.css + ========================= */ + +/* -------------------------------- + Modules - reusable parts of our design +-------------------------------- */ +.cd-container { + width: 90%; + max-width: 1170px; + margin: 0 auto; +} + +.cd-container::after { + content: ''; + display: table; + clear: both; +} + +/* -------------------------------- + Main components (CD Timeline - Legacy viewer) +-------------------------------- */ +.timeline-container { + header { + height: 200px; + line-height: 200px; + text-align: center; + background: #303e49; + } + + header h1 { + color: white; + font-size: 18px; + font-size: 1.125rem; + } + + @media only screen and (min-width: 1170px) { + header { + height: 300px; + line-height: 300px; + } + header h1 { + font-size: 24px; + font-size: 1.5rem; + } + } +} + +#cd-timeline { + position: relative; + padding: 2em 0; + margin-top: 2em; + margin-bottom: 2em; +} + +#cd-timeline::before { + content: ''; + position: absolute; + top: 0; + left: 18px; + height: 100%; + width: 4px; + background: #d7e4ed; +} + +@media only screen and (min-width: 1170px) { + #cd-timeline { + margin-top: 3em; + margin-bottom: 3em; + } + #cd-timeline::before { + left: 50%; + margin-left: -2px; + } +} + +.cd-timeline-block { + position: relative; + margin: 2em 0; +} + +.cd-timeline-block:after { + content: ""; + display: table; + clear: both; +} + +.cd-timeline-block:first-child { + margin-top: 0; +} + +.cd-timeline-block:last-child { + margin-bottom: 0; +} + +@media only screen and (min-width: 1170px) { + .cd-timeline-block { + margin: 4em 0; + } + .cd-timeline-block:first-child { + margin-top: 0; + } + .cd-timeline-block:last-child { + margin-bottom: 0; + } +} + +.cd-timeline-img { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + border-radius: 50%; + box-shadow: 0 0 0 4px white, inset 0 2px 0 rgba(0, 0, 0, 0.08), 0 3px 0 4px rgba(0, 0, 0, 0.05); +} + +.cd-timeline-img i { + display: block; + width: 24px; + height: 24px; + position: relative; + left: 50%; + top: 50%; + margin-left: -12px; + margin-top: -12px; +} + +.cd-timeline-img.cd-picture { + background: #75ce66; +} + +.cd-timeline-img.cd-movie { + background: #c03b44; +} + +.cd-timeline-img.cd-location { + background: #f0ca45; +} + +@media only screen and (min-width: 1170px) { + .cd-timeline-img { + width: 60px; + height: 60px; + left: 50%; + margin-left: -30px; + -webkit-transform: translateZ(0); + -webkit-backface-visibility: hidden; + } + .cssanimations .cd-timeline-img.is-hidden { + visibility: hidden; + } + .cssanimations .cd-timeline-img.bounce-in { + visibility: visible; + -webkit-animation: cd-bounce-1 0.6s; + -moz-animation: cd-bounce-1 0.6s; + animation: cd-bounce-1 0.6s; + } +} + +/* CD Timeline Bounce Animations */ +@-webkit-keyframes cd-bounce-1 { + 0% { opacity: 0; -webkit-transform: scale(0.5); } + 60% { opacity: 1; -webkit-transform: scale(1.2); } + 100% { -webkit-transform: scale(1); } +} + +@-moz-keyframes cd-bounce-1 { + 0% { opacity: 0; -moz-transform: scale(0.5); } + 60% { opacity: 1; -moz-transform: scale(1.2); } + 100% { -moz-transform: scale(1); } +} + +@keyframes cd-bounce-1 { + 0% { opacity: 0; transform: scale(0.5); } + 60% { opacity: 1; transform: scale(1.2); } + 100% { transform: scale(1); } +} + +.cd-timeline-content { + position: relative; + margin-left: 60px; + background: white; + border-radius: 0.25em; + box-shadow: 0 3px 0 #d7e4ed; +} + +.cd-timeline-content.card .card-content { + padding: 5px 10px; +} + +.cd-timeline-content:after { + display: table; + clear: both; +} + +.cd-timeline-content h5 { + color: #303e49; +} + +.cd-timeline-content p, +.cd-timeline-content .cd-read-more, +.cd-timeline-content .cd-date { + font-size: 13px; + font-size: 0.8125rem; +} + +.cd-timeline-content .cd-read-more, +.cd-timeline-content .cd-date { + display: inline-block; +} + +.cd-timeline-content p { + margin: 1em 0; + line-height: 1.6; +} + +.cd-timeline-content .cd-read-more { + float: right; + padding: .8em 1em; + background: #acb7c0; + color: white; + border-radius: 0.25em; +} + +.no-touch .cd-timeline-content .cd-read-more:hover { + background-color: #bac4cb; +} + +.cd-timeline-content .cd-date { + float: left; + padding: .8em 0; + opacity: .7; +} + +.cd-timeline-content::before { + content: ''; + position: absolute; + top: 16px; + right: 100%; + height: 0; + width: 0; + border: 7px solid transparent; + border-right: 7px solid white; +} + +@media only screen and (min-width: 768px) { + .cd-timeline-content h5 { + font-size: 16px; + font-size: 1.25rem; + } + .cd-timeline-content p { + font-size: 16px; + font-size: 1rem; + } + .cd-timeline-content .cd-read-more, + .cd-timeline-content .cd-date { + font-size: 14px; + font-size: 0.875rem; + } +} + +@media only screen and (min-width: 1170px) { + .cd-timeline-content { + margin-left: 0; + width: 45%; + } + .cd-timeline-content::before { + top: 24px; + left: 100%; + border-color: transparent; + border-left-color: white; + } + .cd-timeline-content .cd-read-more { + float: left; + } + .cd-timeline-content .cd-date { + position: absolute; + width: 100%; + left: 122%; + top: 6px; + font-size: 16px; + font-size: 1rem; + } + .cd-timeline-block:nth-child(even) .cd-timeline-content { + float: right; + } + .cd-timeline-block:nth-child(even) .cd-timeline-content::before { + top: 24px; + left: auto; + right: 100%; + border-color: transparent; + border-right-color: white; + } + .cd-timeline-block:nth-child(even) .cd-timeline-content .cd-read-more { + float: right; + } + .cd-timeline-block:nth-child(even) .cd-timeline-content .cd-date { + left: auto; + right: 122%; + text-align: right; + } + .cssanimations .cd-timeline-content.is-hidden { + visibility: hidden; + } + .cssanimations .cd-timeline-content.bounce-in { + visibility: visible; + -webkit-animation: cd-bounce-2 0.6s; + -moz-animation: cd-bounce-2 0.6s; + animation: cd-bounce-2 0.6s; + } +} + +@media only screen and (min-width: 1170px) { + .cssanimations .cd-timeline-block:nth-child(even) .cd-timeline-content.bounce-in { + -webkit-animation: cd-bounce-2-inverse 0.6s; + -moz-animation: cd-bounce-2-inverse 0.6s; + animation: cd-bounce-2-inverse 0.6s; + } +} + +@keyframes cd-bounce-2 { + 0% { opacity: 0; transform: translateX(-100px); } + 60% { opacity: 1; transform: translateX(20px); } + 100% { transform: translateX(0); } +} + +@keyframes cd-bounce-2-inverse { + 0% { opacity: 0; transform: translateX(100px); } + 60% { opacity: 1; transform: translateX(-20px); } + 100% { transform: translateX(0); } +} + +/* ================================ + MODERN TIMELINE EDITOR + Paper-Style Card System + ================================ */ + +/* Container Layout System */ +.timeline-events-container { + position: relative; + min-height: 200px; +} + +/* Timeline Spine - Continuous Vertical Rail */ +.timeline-events-container .timeline-spine { + background: linear-gradient(to bottom, + transparent 0%, + #d1d5db 10%, + #9ca3af 50%, + #d1d5db 90%, + transparent 100% + ); + box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.1); + left: 39px; +} + +.dark .timeline-events-container .timeline-spine { + background: linear-gradient(to bottom, + transparent 0%, + #374151 10%, + #4b5563 50%, + #374151 90%, + transparent 100% + ); +} + +/* Timeline Dots - Connected to Spine */ +.timeline-dot { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + z-index: 10; +} + +.timeline-dot:hover { + transform: scale(1.15); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); + filter: brightness(1.1); +} + +.timeline-dot::before { + content: ''; + position: absolute; + inset: -2px; + border-radius: 50%; + z-index: -1; +} + +/* Paper-Style Event Cards */ +.timeline-event-card { + border-radius: 16px; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: visible; +} + +.timeline-event-card.ring-2 { + border-color: #10b981; + box-shadow: + 0 0 0 2px rgba(16, 185, 129, 0.2), + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transform: translateY(-1px) scale(1.01); +} + +.timeline-event-card:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: + 0 10px 25px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + border-color: #d1d5db; +} + +/* Card Header Styling */ +.timeline-event-card .px-6.py-4.border-b { + border-radius: 16px 16px 0 0; + position: relative; +} + +/* Event Title Input Styling */ +.timeline-event-card input[name*="[title]"] { + font-weight: 600; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 0.25rem 0; + transition: all 0.2s ease; +} + +.timeline-event-card input[name*="[title]"]:focus { + border-bottom-color: #10b981; + box-shadow: 0 2px 4px rgba(16, 185, 129, 0.1); +} + +/* Time Label Input Styling */ +.timeline-event-card input[name*="[time_label]"] { + font-size: 0.875rem; + background: transparent; + border: none; + border-bottom: 1px solid transparent; + padding: 0.125rem 0; + transition: all 0.2s ease; +} + +.timeline-event-card input[name*="[time_label]"]:focus { + border-bottom-color: #10b981; +} + +/* Timeline Header Styling */ +.timeline-header-container { + position: relative; + margin-bottom: 2rem; +} + +.timeline-inline-edit-input { + border-radius: 0.375rem; + padding: 0.25rem 0.5rem; + transition: all 0.2s ease; +} + +.timeline-inline-edit-input:focus { + border-color: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); + outline: none; +} + +.timeline-header-dot { + transition: all 0.2s ease-in-out; + position: relative; + z-index: 10; + background-color: #3b82f6; +} + +.timeline-header-dot:hover { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.timeline-header-card { + border-radius: 16px; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: visible; +} + +.timeline-header-card:hover { + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.timeline-header-toggle { + position: relative; +} + +.timeline-header-description textarea { + transition: all 0.2s ease; + font-family: inherit; + line-height: 1.6; +} + +.timeline-header-description textarea:focus { + box-shadow: + 0 0 0 3px rgba(59, 130, 246, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.05); + outline: none; +} + +/* Timeline Duration Container Styling */ +.timeline-duration-container { + margin-top: 0.5rem; +} + +.timeline-duration-fields { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; +} + +.timeline-duration-start, +.timeline-duration-end { + flex: 1; + min-width: 0; +} + +.timeline-duration-end-section { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + min-width: 0; + animation: slideInRight 0.3s ease-out; +} + +@keyframes slideInRight { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: translateX(0); } +} + +.timeline-duration-field-wrapper { + display: flex; + align-items: center; + gap: 0.375rem; + position: relative; +} + +.timeline-duration-icon { + font-size: 1rem !important; + color: #10b981; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.timeline-duration-input { + flex: 1; + min-width: 0; +} + +.timeline-duration-field-wrapper:focus-within .timeline-duration-icon { + color: #10b981; +} + +.timeline-duration-connector { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0 0.25rem; +} + +.timeline-duration-arrow { + color: #d1d5db; + font-weight: 600; + font-size: 1.25rem; + display: inline-block; +} + +/* Description Textarea Styling */ +.timeline-event-card textarea[name*="[description]"] { + border-radius: 12px; + padding: 0.875rem 1rem; + transition: all 0.2s ease; + font-size: 0.9375rem; + line-height: 1.6; +} + +.timeline-event-card textarea[name*="[description]"]:focus { + box-shadow: + 0 0 0 3px rgba(16, 185, 129, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.05); +} + +/* Action Button Styling */ +.timeline-move-controls button { + background: transparent; + border: none; + padding: 0.5rem; + border-radius: 8px; + color: #9ca3af; + transition: all 0.2s ease; + position: relative; +} + +.timeline-move-controls button:hover { + background: rgba(16, 185, 129, 0.1); + color: #10b981; + transform: scale(1.1); +} + +.timeline-move-controls button:hover[onclick*="delete"] { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.timeline-move-controls .absolute.right-0 { + border-radius: 12px; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +.timeline-move-controls .absolute.right-0 a { + padding: 0.75rem 1rem; + transition: all 0.15s ease; +} + +.timeline-move-controls .absolute.right-0 a:last-child { + border-bottom: none; +} + +/* Timeline Empty State Styling */ +.timeline-events-empty .text-center.py-16, +.timeline-container .text-center.py-16 { + animation: fadeInUp 0.6s ease-out; +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.timeline-events-empty .text-center.py-16 .mx-auto.h-24, +.timeline-container .text-center.py-16 .mx-auto.h-24 { + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: all 0.3s ease; +} + +.timeline-events-empty .text-center.py-16:hover .mx-auto.h-24, +.timeline-container .text-center.py-16:hover .mx-auto.h-24 { + transform: scale(1.05); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* Form Focus States */ +.timeline-event-card input:focus, +.timeline-event-card textarea:focus { + outline: none; +} + +/* Loading Animation for New Events */ +.timeline-event-container.creating { + animation: slideInFromTop 0.4s ease-out; +} + +@keyframes slideInFromTop { + from { opacity: 0; transform: translateY(-20px) scale(0.95); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* Hover Group Effects */ +.timeline-event-container:hover .timeline-dot { + transform: scale(1.2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + filter: brightness(1.15); +} + +/* Card Content Spacing */ +.timeline-event-card .space-y-4 > * + * { + margin-top: 1.5rem; +} + +/* Drag and Drop Styling */ +.timeline-event-drag-handle { + transition: all 0.2s ease-in-out; + border-radius: 8px; +} + +.timeline-event-drag-handle:hover { + background: rgba(16, 185, 129, 0.1) !important; + color: #10b981 !important; + transform: scale(1.1); +} + +.timeline-event-drag-handle:active { + background: rgba(16, 185, 129, 0.2) !important; + transform: scale(0.95); +} + +.timeline-event-container.ui-sortable-helper { + transform: rotate(2deg) !important; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important; + z-index: 1000 !important; + opacity: 0.9; +} + +.timeline-event-placeholder { + height: 120px !important; + background: linear-gradient(45deg, #f3f4f6 25%, transparent 25%), + linear-gradient(-45deg, #f3f4f6 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #f3f4f6 75%), + linear-gradient(-45deg, transparent 75%, #f3f4f6 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + border: 2px dashed #10b981 !important; + border-radius: 16px; + margin: 0 0 2rem 0; + opacity: 0.7; + position: relative; + animation: pulseDropZone 2s infinite; +} + +.timeline-event-placeholder::before { + content: 'Drop event here'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #10b981; + font-weight: 600; + font-size: 0.875rem; + text-shadow: 0 2px 4px rgba(255, 255, 255, 0.8); +} + +@keyframes pulseDropZone { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +.timeline-event-dragging { + transform: rotate(2deg); + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); + z-index: 1000; + opacity: 0.9; +} + +/* Template visibility */ +.timeline-event-template { + display: none; +} + +.loading-indicator { + display: none; +} + +/* Tag Autocomplete Styling */ +.tag-autocomplete-dropdown { + backdrop-filter: blur(8px); +} + +.tag-autocomplete-dropdown .tag-suggestion-item { + transition: all 0.15s ease; + border-radius: 6px; + margin: 2px 4px; +} + +.tag-autocomplete-dropdown .tag-suggestion-item:hover { + background: rgba(59, 130, 246, 0.1); + transform: translateX(2px); +} + +.tag-autocomplete-dropdown .timeline-tag-item:hover { + background: rgba(59, 130, 246, 0.1); + border-left: 3px solid #3b82f6; +} + +.tag-autocomplete-dropdown .suggested-tag-item:hover { + background: rgba(156, 163, 175, 0.1); + border-left: 3px solid #9ca3af; +} + +.tag-autocomplete-dropdown .custom-tag-item:hover { + background: rgba(16, 185, 129, 0.1); + border-left: 3px solid #10b981; +} + +@keyframes tagSelect { + 0% { transform: scale(1); background: rgba(16, 185, 129, 0.1); } + 50% { transform: scale(1.05); background: rgba(16, 185, 129, 0.3); } + 100% { transform: scale(1); background: transparent; } +} + +.tag-selected-animation { + animation: tagSelect 0.3s ease-out; +} + +.tag-input-focused { + box-shadow: + 0 0 0 3px rgba(59, 130, 246, 0.1), + 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border-color: #3b82f6; +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .timeline-event-card { + margin-left: 0; + border-radius: 12px; + } + + .timeline-dot { + position: absolute; + } + + .timeline-duration-fields { + gap: 0.5rem; + } + + .timeline-duration-icon { + font-size: 0.9rem !important; + } + + .timeline-header-toggle { + flex-direction: column; + align-items: flex-start; + space-y: 2px; + } + + .timeline-header-toggle .flex.items-center.space-x-3:last-child { + width: 100%; + justify-content: flex-end; + margin-top: 0.5rem; + } +} + +@media (max-width: 768px) { + .timeline-event-card { + border-radius: 8px; + margin-bottom: 1rem; + } + + .timeline-event-card .px-6.py-4 { + padding: 1rem 1.25rem; + } + + .timeline-event-drag-handle { + padding: 0.75rem !important; + margin-right: 0.5rem !important; + } + + .timeline-event-placeholder { + height: 100px !important; + border-radius: 12px; + } + + .timeline-duration-fields { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .timeline-duration-end-section { + flex-direction: column; + gap: 0.5rem; + } + + .timeline-duration-connector { + transform: rotate(90deg); + margin: 0.25rem 0; + } + + .timeline-duration-arrow { + font-size: 1rem; + } + + .timeline-duration-end { + width: 100%; + } + + .timeline-duration-field-wrapper { + width: 100%; + } + + .timeline-header-card { + margin-left: 0; + border-radius: 12px; + } + + .timeline-header-dot { + position: relative; + left: auto !important; + top: auto; + margin-bottom: 1rem; + margin-left: 1rem; + } + + .timeline-header-toggle { + padding: 1rem 1.25rem; + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .timeline-header-toggle .flex.items-center.space-x-3:last-child { + width: 100%; + justify-content: space-between; + } + + .timeline-header-description { + padding: 1rem 1.25rem; + } +} diff --git a/app/assets/stylesheets/timeline_editor_consolidated.scss b/app/assets/stylesheets/timeline_editor_consolidated.scss new file mode 100644 index 000000000..51bd8a9d8 --- /dev/null +++ b/app/assets/stylesheets/timeline_editor_consolidated.scss @@ -0,0 +1,873 @@ +/* ========================= + TIMELINE EDITOR STYLES + Edit mode only styles + Consolidated from: timeline-editor.scss + timeline_sidebar.css + + timeline_inspector.css + timeline_linked_content.css + ========================= */ + +/* ================================ + TEMPLATE VISIBILITY + ================================ */ +.timeline-event-template { + display: none; +} + +.loading-indicator { + display: none; +} + +/* ================================ + LEFT SIDEBAR STYLING + Modern Tool Panel Design + ================================ */ + +.timeline-sidebar { + background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%); + border-right: 1px solid #e5e7eb; + box-shadow: + inset -1px 0 0 0 rgba(255, 255, 255, 0.5), + 2px 0 8px -2px rgba(0, 0, 0, 0.05); + position: relative; + overflow: hidden; +} + +.timeline-sidebar::before { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 1px; + background: linear-gradient(180deg, + transparent 0%, + rgba(16, 185, 129, 0.1) 50%, + transparent 100% + ); +} + +.timeline-sidebar .px-6.py-4.border-b { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid #e2e8f0; + position: relative; +} + +.timeline-sidebar .px-6.py-4.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #cbd5e1 50%, + transparent 100% + ); +} + +.timeline-sidebar .p-6.border-b { + border-bottom: 1px solid #f1f5f9; + position: relative; + transition: all 0.2s ease; +} + +.timeline-sidebar .p-6.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 1.5rem; + right: 1.5rem; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #e2e8f0 50%, + transparent 100% + ); +} + +.timeline-sidebar .p-6.border-b:hover { + background: rgba(248, 250, 252, 0.5); +} + +/* Sidebar Search Input */ +.timeline-sidebar input[type="text"] { + background: white; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 0.75rem 1rem 0.75rem 2.5rem; + font-size: 0.875rem; + transition: all 0.2s ease; + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.05), + inset 0 1px 0 0 rgba(255, 255, 255, 0.05); +} + +.timeline-sidebar input[type="text"]:hover { + background: #fefefe; + border-color: #d1d5db; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar input[type="text"]:focus { + background: white; + border-color: #10b981; + box-shadow: + 0 0 0 3px rgba(16, 185, 129, 0.1), + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + outline: none; +} + +.timeline-sidebar .absolute.inset-y-0.left-0 { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + color: #9ca3af; + transition: color 0.2s ease; +} + +.timeline-sidebar input[type="text"]:focus + .absolute.inset-y-0.left-0, +.timeline-sidebar input[type="text"]:hover + .absolute.inset-y-0.left-0 { + color: #10b981; +} + +/* Sidebar Buttons */ +.timeline-sidebar button { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.timeline-sidebar button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.2) 50%, + transparent 100% + ); + transition: left 0.5s ease; +} + +.timeline-sidebar button:hover::before { + left: 100%; +} + +.timeline-sidebar .bg-green-600 { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + border: 1px solid #059669; + box-shadow: + 0 2px 4px 0 rgba(16, 185, 129, 0.2), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar .bg-green-600:hover { + background: linear-gradient(135deg, #059669 0%, #047857 100%); + transform: translateY(-1px); + box-shadow: + 0 4px 8px 0 rgba(16, 185, 129, 0.3), + 0 2px 4px 0 rgba(0, 0, 0, 0.1); +} + +.timeline-sidebar .bg-green-600:active { + transform: translateY(0); + box-shadow: + 0 1px 2px 0 rgba(16, 185, 129, 0.2), + 0 1px 1px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar .bg-white.border { + background: white; + border: 1px solid #e5e7eb; + box-shadow: + 0 1px 2px 0 rgba(0, 0, 0, 0.05), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1); +} + +.timeline-sidebar .bg-white.border:hover { + background: #f9fafb; + border-color: #d1d5db; + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.timeline-sidebar .bg-white.border:active { + transform: translateY(0); + background: #f3f4f6; +} + +/* Sidebar Stats */ +.timeline-sidebar .space-y-3 { + padding: 0.5rem 0; +} + +.timeline-sidebar .flex.justify-between.items-center { + padding: 0.5rem 0; + border-radius: 8px; + transition: all 0.15s ease; + position: relative; +} + +.timeline-sidebar .flex.justify-between.items-center:hover { + background: rgba(16, 185, 129, 0.05); + transform: translateX(4px); +} + +.timeline-sidebar .flex.justify-between.items-center::before { + content: ''; + position: absolute; + left: -0.5rem; + top: 0; + bottom: 0; + width: 3px; + background: #10b981; + border-radius: 0 2px 2px 0; + opacity: 0; + transition: opacity 0.15s ease; +} + +.timeline-sidebar .flex.justify-between.items-center:hover::before { + opacity: 1; +} + +.timeline-sidebar .text-sm.text-gray-600 { + color: #6b7280; + font-weight: 500; + transition: color 0.15s ease; +} + +.timeline-sidebar .text-sm.font-medium.text-gray-900 { + color: #1f2937; + font-weight: 600; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + transition: all 0.15s ease; +} + +/* Sidebar Headers */ +.timeline-sidebar h3, +.timeline-sidebar h4 { + color: #1f2937; + font-weight: 600; + position: relative; +} + +.timeline-sidebar h3::after, +.timeline-sidebar h4::after { + content: ''; + position: absolute; + bottom: -0.25rem; + left: 0; + width: 2rem; + height: 2px; + background: linear-gradient(90deg, #10b981 0%, transparent 100%); + border-radius: 1px; +} + +/* Sidebar Scrollbar */ +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar-track { + background: transparent; +} + +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.timeline-sidebar .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +/* Sidebar Mobile */ +@media (max-width: 1024px) { + .timeline-sidebar { + position: fixed; + top: 0; + left: -100%; + width: 20rem; + height: 100vh; + z-index: 50; + transition: left 0.3s ease; + box-shadow: + 2px 0 10px 0 rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.05); + } + + .timeline-sidebar.open { + left: 0; + } + + .timeline-sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + + .timeline-sidebar-backdrop.open { + opacity: 1; + pointer-events: auto; + } +} + +.timeline-sidebar button:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +.timeline-sidebar input:focus { + outline: none; +} + +.timeline-sidebar button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.timeline-sidebar button:disabled:hover { + transform: none !important; + box-shadow: none !important; +} + +@keyframes slideInFromLeft { + from { transform: translateX(-100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +.timeline-sidebar.animate-in { + animation: slideInFromLeft 0.3s ease-out; +} + +.timeline-sidebar::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.02) 1px, transparent 0); + background-size: 20px 20px; + pointer-events: none; + z-index: 1; +} + +.timeline-sidebar > * { + position: relative; + z-index: 2; +} + +/* ================================ + RIGHT INSPECTOR PANEL + Modern Details & Linking UI + ================================ */ + +.timeline-inspector { + background: linear-gradient(135deg, #ffffff 0%, #fefefe 100%); + border-left: 1px solid #e5e7eb; + box-shadow: + inset 1px 0 0 0 rgba(255, 255, 255, 0.5), + -2px 0 8px -2px rgba(0, 0, 0, 0.05); + position: relative; + overflow: hidden; +} + +.timeline-inspector::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 1px; + background: linear-gradient(180deg, + transparent 0%, + rgba(16, 185, 129, 0.1) 50%, + transparent 100% + ); +} + +.timeline-inspector .px-6.py-4.border-b { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border-bottom: 1px solid #e2e8f0; + position: relative; +} + +.timeline-inspector .px-6.py-4.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #cbd5e1 50%, + transparent 100% + ); +} + +.timeline-inspector h4 { + color: #1f2937; + font-weight: 600; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.timeline-inspector .px-3.py-1 { + transition: all 0.2s ease; + backdrop-filter: blur(8px); + border: 1px solid transparent; +} + +.timeline-inspector .bg-blue-100 { + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + border-color: rgba(59, 130, 246, 0.2); +} + +.timeline-inspector .bg-yellow-100 { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-color: rgba(245, 158, 11, 0.2); +} + +.timeline-inspector .bg-gray-50 { + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + border: 1px solid #e5e7eb; + transition: all 0.2s ease; +} + +.timeline-inspector .bg-gray-50:hover { + background: linear-gradient(135deg, #ffffff 0%, #f9fafb 100%); + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(0, 0, 0, 0.05), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.timeline-inspector button { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; +} + +.timeline-inspector button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.2) 50%, + transparent 100% + ); + transition: left 0.5s ease; +} + +.timeline-inspector button:hover::before { + left: 100%; +} + +.timeline-inspector .bg-green-100 { + background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); + border: 1px solid #6ee7b7; + box-shadow: + 0 1px 2px 0 rgba(16, 185, 129, 0.1), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1); +} + +.timeline-inspector .bg-green-100:hover { + background: linear-gradient(135deg, #a7f3d0 0%, #6ee7b7 100%); + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(16, 185, 129, 0.2), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.timeline-inspector .bg-red-50 { + background: linear-gradient(135deg, #fef2f2 0%, #fecaca 100%); + border: 1px solid #fca5a5; + box-shadow: + 0 1px 2px 0 rgba(239, 68, 68, 0.1), + inset 0 1px 0 0 rgba(255, 255, 255, 0.1); +} + +.timeline-inspector .bg-red-50:hover { + background: linear-gradient(135deg, #fecaca 0%, #f87171 100%); + transform: translateY(-1px); + box-shadow: + 0 2px 4px 0 rgba(239, 68, 68, 0.2), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.timeline-inspector .text-gray-500 { + animation: inspectorFadeIn 0.3s ease-out; +} + +@keyframes inspectorFadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.timeline-inspector .bg-gray-50.rounded-lg { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid #e2e8f0; + border-left: 3px solid #10b981; +} + +.timeline-inspector .border-t, +.timeline-inspector .border-b { + border-color: #f1f5f9; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar-track { + background: transparent; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.timeline-inspector .overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +@media (max-width: 1280px) { + .timeline-inspector { + position: fixed; + top: 0; + right: -100%; + width: 24rem; + height: 100vh; + z-index: 50; + transition: right 0.3s ease; + box-shadow: + -2px 0 10px 0 rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.05); + } + + .timeline-inspector.open { + right: 0; + } + + .timeline-inspector-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + + .timeline-inspector-backdrop.open { + opacity: 1; + pointer-events: auto; + } +} + +.timeline-inspector button:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +.timeline-inspector button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; +} + +.timeline-inspector button:disabled:hover { + transform: none !important; + box-shadow: none !important; +} + +.timeline-inspector::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.02) 1px, transparent 0); + background-size: 20px 20px; + pointer-events: none; + z-index: 1; +} + +.timeline-inspector > * { + position: relative; + z-index: 2; +} + +.timeline-inspector [x-show] { + transition: all 0.2s ease-out; +} + +.timeline-inspector h5 { + position: relative; + color: #1f2937; + font-weight: 600; +} + +.timeline-inspector h5::after { + content: ''; + position: absolute; + bottom: -0.25rem; + left: 0; + width: 1.5rem; + height: 2px; + background: linear-gradient(90deg, #10b981 0%, transparent 100%); + border-radius: 1px; +} + +/* ================================ + LINKED CONTENT STYLING + Paper-Style Card Integration + ================================ */ + +.linked-content-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.3) transparent; +} + +.linked-content-breakout { + width: 100%; + margin-left: 0; + margin-right: 0; +} + +.linked-content-scroll::-webkit-scrollbar { + height: 6px; +} + +.linked-content-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.linked-content-scroll::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.3); + border-radius: 3px; +} + +.linked-content-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(156, 163, 175, 0.5); +} + +/* Text Truncation */ +.line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.linked-content-card { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 12px; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + position: relative; + overflow: hidden; +} + +.linked-content-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, + #10b981 0%, + #059669 50%, + #10b981 100% + ); + opacity: 0; + transition: opacity 0.3s ease; +} + +.linked-content-card:hover { + transform: translateY(-3px) scale(1.03); + box-shadow: + 0 10px 25px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.linked-content-card:hover::before { + opacity: 1; +} + +.type-badge { + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + backdrop-filter: blur(8px); + border-radius: 8px; + transition: all 0.2s ease; +} + +.linked-content-card:hover .type-badge { + transform: translateY(-1px); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.remove-btn { + transition: all 0.2s ease-in-out; + box-shadow: + 0 1px 3px 0 rgba(0, 0, 0, 0.1), + 0 1px 2px 0 rgba(0, 0, 0, 0.06); + backdrop-filter: blur(8px); + border-radius: 8px; +} + +.remove-btn:hover { + transform: scale(1.1) translateY(-1px); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.name-card-overlay { + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.gradient-overlay { + background: linear-gradient(to top, + rgba(0, 0, 0, 0.7) 0%, + rgba(0, 0, 0, 0.4) 30%, + rgba(0, 0, 0, 0.1) 60%, + transparent 80% + ); + border-radius: 0 0 12px 12px; +} + +@keyframes slideInFromRightCard { + from { opacity: 0; transform: translateX(20px) scale(0.95); } + 50% { transform: translateX(-2px) scale(1.02); } + to { opacity: 1; transform: translateX(0) scale(1); } +} + +.new-linked-card { + animation: slideInFromRightCard 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.linked-content-card:hover .name-card-overlay { + transform: translateY(-2px); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.linked-content-card:hover .type-badge { + transform: scale(1.05) translateY(-1px); +} + +.linked-content-card::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%), + linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.03) 49%, rgba(255,255,255,0.03) 51%, transparent 52%); + background-size: 2px 2px; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease; +} + +.linked-content-card:hover::after { + opacity: 1; +} + +.linked-content-card { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.linked-content-card a { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + +@media (max-width: 640px) { + .linked-content-card { + width: 144px; + height: 80px; + } + + .type-badge { + padding: 4px 8px; + font-size: 0.625rem; + } + + .type-badge .material-icons { + font-size: 0.625rem; + } +} + +.linked-content-card:focus-within { + outline: 2px solid #10B981; + outline-offset: 2px; + transform: translateY(-2px); + box-shadow: + 0 0 0 4px rgba(16, 185, 129, 0.1), + 0 10px 25px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.linked-content-card a:focus { + outline: none; + text-decoration: underline; +} diff --git a/app/assets/stylesheets/timeline_viewer.scss b/app/assets/stylesheets/timeline_viewer.scss new file mode 100644 index 000000000..5a71705a2 --- /dev/null +++ b/app/assets/stylesheets/timeline_viewer.scss @@ -0,0 +1,273 @@ +/* ========================= + TIMELINE VIEWER STYLES + View mode only styles + Renamed from: timeline_viewer.css + ========================= */ + +/* Timeline spine positioning and styling */ +.timeline-viewer-container { + position: relative; +} + +.timeline-viewer-container .timeline-spine { + left: 1.5rem; + width: 2px; +} + +/* Timeline dots and connections */ +.timeline-viewer-container .timeline-dot, +.timeline-viewer-container .timeline-header-dot { + width: 1.5rem; + height: 1.5rem; + border: 3px solid white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Enhanced hover effects for event cards */ +.timeline-event-card:hover { + transform: translateY(-2px); +} + +/* Floating author attribution animations */ +.floating-author { + animation: viewerFadeInUp 0.6s ease-out 0.5s both; +} + +@keyframes viewerFadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Linked content styling */ +.linked-content-item { + overflow: hidden; +} + +.linked-content-item:hover { + transform: translateY(-1px); +} + +/* Aspect ratio utility for content previews (fallback for older browsers) */ +.aspect-w-16 { + position: relative; + width: 100%; +} + +.aspect-w-16::before { + content: ''; + display: block; + padding-bottom: calc(9 / 16 * 100%); +} + +.aspect-h-9 > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Line clamping for text overflow */ +.viewer-line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Smooth transitions for interactive elements */ +.timeline-header-toggle, +.timeline-event-card, +.linked-content-item { + transition: all 0.2s ease-in-out; +} + +/* Focus states for accessibility */ +.timeline-header-toggle:focus, +.timeline-event-card:focus { + outline: none; + ring: 2px solid #10b981; + ring-opacity: 50%; +} + +/* Timeline spacing and flow */ +.timeline-event-container:last-child { + margin-bottom: 0; +} + +.timeline-header-container { + margin-bottom: 2rem; +} + +/* Mobile responsiveness improvements */ +@media (max-width: 768px) { + .timeline-viewer-container { + padding: 0 1rem; + } + + .timeline-spine, + .timeline-spine-accent { + left: 1.5rem; + } + + .timeline-dot, + .timeline-header-dot { + left: 1.5rem; + transform: translateX(-50%); + } + + .timeline-event-card { + margin-left: 2.5rem; + border-radius: 1.5rem; + } + + /* Event header mobile adjustments */ + .timeline-event-card .px-10 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .timeline-event-card h3 { + font-size: 1.5rem; + } + + /* Adjust linked content for mobile - keep horizontal scroll */ + .linked-content-group .overflow-x-auto { + padding-bottom: 4px; + } + + /* Hide floating author on mobile */ + .fixed.bottom-8.right-8 { + display: none; + } +} + +/* Print styles for timeline viewing */ +@media print { + .timeline-viewer-container { + color: black; + } + + .timeline-spine { + background-color: #666; + } + + .timeline-event-card, + .timeline-header-card { + box-shadow: none; + border: 1px solid #ccc; + } + + .linked-content-item { + break-inside: avoid; + } + + .linked-content-group .overflow-x-auto { + overflow-x: visible; + } + + .linked-content-group .flex { + flex-wrap: wrap; + gap: 0.75rem; + } +} + +/* Animation for content loading */ +.timeline-event-container { + animation: viewerFadeInUp 0.3s ease-out; + animation-fill-mode: both; +} + +/* Stagger animation for multiple events */ +.timeline-event-container:nth-child(1) { animation-delay: 0.1s; } +.timeline-event-container:nth-child(2) { animation-delay: 0.15s; } +.timeline-event-container:nth-child(3) { animation-delay: 0.2s; } +.timeline-event-container:nth-child(4) { animation-delay: 0.25s; } +.timeline-event-container:nth-child(5) { animation-delay: 0.3s; } +.timeline-event-container:nth-child(n+6) { animation-delay: 0.35s; } + +/* Enhanced typography for better readability */ +.prose h1, +.prose h2, +.prose h3, +.prose h4, +.prose h5, +.prose h6 { + margin-top: 0; +} + +.prose p { + margin-bottom: 0.75rem; +} + +.prose p:last-child { + margin-bottom: 0; +} + +/* Timeline viewer specific utilities */ +.timeline-viewer-sticky-header { + backdrop-filter: blur(8px); + background-color: rgba(255, 255, 255, 0.95); +} + +/* Linked Content Viewer Styles */ +.linked-content-group { + --card-width: 12rem; + --card-height: 7.5rem; +} + +.w-50 { + width: var(--card-width); +} + +.h-30 { + height: var(--card-height); +} + +/* Scrollbar styling for horizontal scroll */ +.linked-content-group .overflow-x-auto::-webkit-scrollbar { + height: 6px; +} + +.linked-content-group .overflow-x-auto::-webkit-scrollbar-track { + background: #f3f4f6; + border-radius: 3px; +} + +.linked-content-group .overflow-x-auto::-webkit-scrollbar-thumb { + background: #d1d5db; + border-radius: 3px; +} + +.linked-content-group .overflow-x-auto::-webkit-scrollbar-thumb:hover { + background: #9ca3af; +} + +/* Enhanced hover effects for linked content cards */ +.linked-content-group .group:hover { + transform: translateY(-2px); +} + +/* Better text contrast on overlay */ +.linked-content-group .group h6 { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.linked-content-group .group p { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +/* Responsive adjustments for linked content */ +@media (max-width: 640px) { + .linked-content-group { + --card-width: 10rem; + --card-height: 6rem; + } +} diff --git a/app/assets/stylesheets/topic-show.scss b/app/assets/stylesheets/topic-show.scss new file mode 100644 index 000000000..3858b0c96 --- /dev/null +++ b/app/assets/stylesheets/topic-show.scss @@ -0,0 +1,436 @@ +// Topic Show Page Styling +// Note: Topic header now uses inline Tailwind classes in show.html.erb +// Note: Post cards now use inline Tailwind classes in _post.html.erb + +// Content area - narrower column +.topic-content-area { + max-width: 48rem; // 768px - narrower than the header + margin: 0 auto; + padding: 0 1rem; + + // Posts container + .posts-container { + margin-bottom: 1rem; + } +} + +// Individual post styling - compact +.thredded--post { + margin-bottom: 0.75rem; + position: relative; + + // Override default thredded styles + background: transparent; + border: none; + box-shadow: none; + overflow: visible; + + // The speech bubble content styling + .prose { + font-size: 0.9375rem; + line-height: 1.65; + + p { + margin-bottom: 0.75rem; + + &:last-child { + margin-bottom: 0; + } + } + + blockquote { + margin: 0.75rem 0; + padding-left: 1rem; + border-left: 3px solid #2196F3; + color: #6b7280; + font-style: italic; + background: #f9fafb; + padding: 0.75rem 1rem; + border-radius: 0.25rem; + } + + code { + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 3px; + font-size: 0.875rem; + color: #d97706; + } + + pre { + background: #1f2937; + color: #f3f4f6; + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin: 0.75rem 0; + + code { + background: transparent; + padding: 0; + color: inherit; + } + } + + a { + color: #2196F3; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.15s ease; + + &:hover { + border-bottom-color: #2196F3; + } + } + + ul { + margin: 0.75rem 0; + padding-left: 1.5rem; + list-style-type: disc; + } + + ol { + margin: 0.75rem 0; + padding-left: 1.5rem; + list-style-type: decimal; + } + + li { + margin: 0.25rem 0; + } + } + + // First unread post indicator + &.thredded--unread--post { + .post-content-card { + border-bottom: 3px solid #2196F3; + padding-bottom: calc(1rem - 3px); // Adjust padding to account for border + } + } +} + +// Post actions dropdown styling +.thredded--post--dropdown { + position: relative; + display: inline-block; + + .thredded--post--dropdown--toggle { + width: 20px; + height: 20px; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + + .thredded--post--dropdown--actions { + position: absolute; + right: 0; + top: 100%; + margin-top: 0.25rem; + background: white; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + min-width: 140px; + display: none; + z-index: 10; + + a { + display: block; + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + color: #6b7280; + text-decoration: none; + transition: all 0.15s ease; + + &:hover { + background: #f9fafb; + color: #2196F3; + } + + &:first-child { + border-radius: 6px 6px 0 0; + } + + &:last-child { + border-radius: 0 0 6px 6px; + } + } + } + + &:hover .thredded--post--dropdown--actions, + .thredded--post--dropdown--toggle:focus+.thredded--post--dropdown--actions { + display: block; + } +} + +// Reply form - compact +.reply-form-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + margin-top: 1rem; + + .reply-form-header { + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%); + border-bottom: 1px solid #e5e7eb; + + h3 { + font-size: 1.125rem; + font-weight: 600; + color: #111827; + margin: 0; + display: flex; + align-items: center; + + .material-icons { + font-size: 1.25rem; + margin-right: 0.5rem; + color: #2196F3; + } + } + } + + .reply-form-body { + padding: 1rem; + } +} + +// Pagination styling - compact +.topic-pagination { + display: flex; + justify-content: center; + margin: 0.75rem 0; + + .pagination { + display: flex; + gap: 0.25rem; + + a, + span { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; + border-radius: 4px; + text-decoration: none; + transition: all 0.15s ease; + } + + a { + background: white; + border: 1px solid #e5e7eb; + color: #374151; + + &:hover { + background: #f9fafb; + border-color: #d1d5db; + } + } + + .current { + background: #2196F3; + color: white; + border: 1px solid #2196F3; + } + + .disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} + +// Locked topic notice +.locked-notice { + background: #fef3c7; + border: 1px solid #fbbf24; + border-radius: 6px; + padding: 1rem; + margin: 1.5rem 0; + display: flex; + align-items: flex-start; + gap: 0.75rem; + + .material-icons { + color: #f59e0b; + font-size: 1.25rem; + flex-shrink: 0; + } + + p { + margin: 0; + color: #92400e; + font-size: 0.875rem; + line-height: 1.4; + } +} + +// Dark mode support +.dark { + // Note: Topic header now uses inline Tailwind dark: classes in show.html.erb + // Note: Post cards now use inline Tailwind dark: classes in _post.html.erb + + // Prose dark mode enhancements for post content + .thredded--post { + .prose { + blockquote { + border-left-color: #3b82f6; + color: #d1d5db; + background: #111827; + } + + code { + background: #374151; + color: #f3f4f6; + } + + pre { + background: #111827; + } + } + } + + .reply-form-section { + background: #1f2937; + border-color: #374151; + + .reply-form-header { + background: linear-gradient(135deg, #111827 0%, #0f172a 100%); + border-bottom-color: #374151; + + h3 { + color: #f3f4f6; + } + } + } + + .locked-notice { + background: #451a03; + border-color: #92400e; + + .material-icons { + color: #fbbf24; + } + + p { + color: #fef3c7; + } + } + + // Pagination dark mode + .topic-pagination { + .pagination { + a { + background: #374151; + border-color: #4b5563; + color: #e5e7eb; + + &:hover { + background: #4b5563; + border-color: #6b7280; + } + } + + .current { + background: #3b82f6; + border-color: #3b82f6; + } + } + } +} + +// ============================================= +// Edit Topic Page Styles +// ============================================= + +.edit-topic-container { + padding: 1.5rem 0; +} + +.edit-topic-header { + h1 { + display: flex; + align-items: center; + gap: 0.5rem; + } +} + +.edit-topic-form-card { + // Form input overrides for consistent styling + input[type="text"], + select, + textarea { + font-family: inherit; + + &:focus { + outline: none; + } + } + + // Checkbox styling improvements + input[type="checkbox"] { + accent-color: #2563eb; + } +} + +// Dark mode for edit topic +.dark { + .edit-topic-form-card { + input[type="text"], + select, + textarea { + &::placeholder { + color: #6b7280; + } + } + + // Checkbox in dark mode + input[type="checkbox"] { + accent-color: #3b82f6; + } + } +} + +// Mobile responsiveness +@media (max-width: 768px) { + .topic-header-section { + margin-left: -1.5rem; + margin-right: -1.5rem; + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .topic-content-area { + padding: 0 1rem; + } + + .thredded--post { + + .thredded--post--user, + .thredded--post--content, + .thredded--post--actions { + padding-left: 1rem; + padding-right: 1rem; + } + } + + .edit-topic-container { + padding: 1rem 0; + } + + .edit-topic-form-card { + .p-6 { + padding: 1rem; + } + + .px-6 { + padding-left: 1rem; + padding-right: 1rem; + } + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/vendor/medium-editor.min.css b/app/assets/stylesheets/vendor/medium-editor.min.css new file mode 100644 index 000000000..e46f81c02 --- /dev/null +++ b/app/assets/stylesheets/vendor/medium-editor.min.css @@ -0,0 +1 @@ +.medium-editor-anchor-preview,.medium-editor-toolbar{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:16px;z-index:2000}@-webkit-keyframes medium-editor-image-loading{0%{-webkit-transform:scale(0);transform:scale(0)}100%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes medium-editor-image-loading{0%{-webkit-transform:scale(0);transform:scale(0)}100%{-webkit-transform:scale(1);transform:scale(1)}}@-webkit-keyframes medium-editor-pop-upwards{0%{opacity:0;-webkit-transform:matrix(.97,0,0,1,0,12);transform:matrix(.97,0,0,1,0,12)}20%{opacity:.7;-webkit-transform:matrix(.99,0,0,1,0,2);transform:matrix(.99,0,0,1,0,2)}40%{opacity:1;-webkit-transform:matrix(1,0,0,1,0,-1);transform:matrix(1,0,0,1,0,-1)}100%{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}}@keyframes medium-editor-pop-upwards{0%{opacity:0;-webkit-transform:matrix(.97,0,0,1,0,12);transform:matrix(.97,0,0,1,0,12)}20%{opacity:.7;-webkit-transform:matrix(.99,0,0,1,0,2);transform:matrix(.99,0,0,1,0,2)}40%{opacity:1;-webkit-transform:matrix(1,0,0,1,0,-1);transform:matrix(1,0,0,1,0,-1)}100%{-webkit-transform:matrix(1,0,0,1,0,0);transform:matrix(1,0,0,1,0,0)}}.medium-editor-anchor-preview{left:0;line-height:1.4;max-width:280px;position:absolute;text-align:center;top:0;word-break:break-all;word-wrap:break-word;visibility:hidden}.medium-editor-anchor-preview a{color:#fff;display:inline-block;margin:5px 5px 10px}.medium-editor-placeholder-relative:after,.medium-editor-placeholder:after{content:attr(data-placeholder)!important;white-space:pre;padding:inherit;margin:inherit;font-style:italic}.medium-editor-anchor-preview-active{visibility:visible}.medium-editor-dragover{background:#ddd}.medium-editor-image-loading{-webkit-animation:medium-editor-image-loading 1s infinite ease-in-out;animation:medium-editor-image-loading 1s infinite ease-in-out;background-color:#333;border-radius:100%;display:inline-block;height:40px;width:40px}.medium-editor-placeholder{position:relative}.medium-editor-placeholder:after{position:absolute;left:0;top:0}.medium-editor-placeholder-relative,.medium-editor-placeholder-relative:after{position:relative}.medium-toolbar-arrow-over:before,.medium-toolbar-arrow-under:after{border-style:solid;content:'';display:block;height:0;left:50%;margin-left:-8px;position:absolute;width:0}.medium-toolbar-arrow-under:after{border-width:8px 8px 0}.medium-toolbar-arrow-over:before{border-width:0 8px 8px;top:-8px}.medium-editor-toolbar{left:0;position:absolute;top:0;visibility:hidden}.medium-editor-toolbar ul{margin:0;padding:0}.medium-editor-toolbar li{float:left;list-style:none;margin:0;padding:0}.medium-editor-toolbar li button{box-sizing:border-box;cursor:pointer;display:block;font-size:14px;line-height:1.33;margin:0;padding:15px;text-decoration:none}.medium-editor-toolbar li button:focus{outline:0}.medium-editor-toolbar li .medium-editor-action-underline{text-decoration:underline}.medium-editor-toolbar li .medium-editor-action-pre{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;font-size:12px;font-weight:100;padding:15px 0}.medium-editor-toolbar-active{visibility:visible}.medium-editor-sticky-toolbar{position:fixed;top:1px}.medium-editor-relative-toolbar{position:relative}.medium-editor-toolbar-active.medium-editor-stalker-toolbar{-webkit-animation:medium-editor-pop-upwards 160ms forwards linear;animation:medium-editor-pop-upwards 160ms forwards linear}.medium-editor-action-bold{font-weight:bolder}.medium-editor-action-italic{font-style:italic}.medium-editor-toolbar-form{display:none}.medium-editor-toolbar-form a,.medium-editor-toolbar-form input{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.medium-editor-toolbar-form .medium-editor-toolbar-form-row{line-height:14px;margin-left:5px;padding-bottom:5px}.medium-editor-toolbar-form .medium-editor-toolbar-input,.medium-editor-toolbar-form label{border:none;box-sizing:border-box;font-size:14px;margin:0;padding:6px;width:316px;display:inline-block}.medium-editor-toolbar-form .medium-editor-toolbar-input:focus,.medium-editor-toolbar-form label:focus{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;box-shadow:none;outline:0}.medium-editor-toolbar-form a{display:inline-block;font-size:24px;font-weight:bolder;margin:0 10px;text-decoration:none}.medium-editor-toolbar-form-active{display:block}.medium-editor-toolbar-actions:after{clear:both;content:"";display:table}.medium-editor-element{word-wrap:break-word;min-height:30px}.medium-editor-element img{max-width:100%}.medium-editor-element sub{vertical-align:sub}.medium-editor-element sup{vertical-align:super}.medium-editor-hidden{display:none} \ No newline at end of file diff --git a/app/authorizers/book_authorizer.rb b/app/authorizers/book_authorizer.rb new file mode 100644 index 000000000..7393cdb6d --- /dev/null +++ b/app/authorizers/book_authorizer.rb @@ -0,0 +1,30 @@ +class BookAuthorizer < ApplicationAuthorizer + def self.creatable_by?(user) + return false unless user.present? + return false if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(user.email) + + true # Free for all users + end + + def readable_by?(user) + return true if resource.privacy == 'public' + return true if user && resource.user_id == user.id + return true if resource.universe.present? && resource.universe.privacy == 'public' + return true if user && resource.universe.present? && resource.universe.user_id == user.id + return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id) + return true if user && user.site_administrator? + + false + end + + def updatable_by?(user) + return true if user && resource.user_id == user.id + return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id) + + false + end + + def deletable_by?(user) + user && resource.user_id == user.id + end +end diff --git a/app/authorizers/timeline_authorizer.rb b/app/authorizers/timeline_authorizer.rb index 1a4e3a0d0..837cc1bd5 100644 --- a/app/authorizers/timeline_authorizer.rb +++ b/app/authorizers/timeline_authorizer.rb @@ -18,9 +18,10 @@ def readable_by?(user) end def updatable_by?(user) - [ - user && resource.user_id == user.id - ].any? + return true if user && resource.user_id == user.id + return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id) + + return false end def deletable_by?(user) diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index d31077989..932c9a463 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -1,14 +1,23 @@ class AdminController < ApplicationController - layout 'admin' - layout 'application', only: [:unsubscribe, :perform_unsubscribe] - before_action :authenticate_user! before_action :require_admin_access, unless: -> { Rails.env.development? } def dashboard + @days = params.fetch(:days, 30).to_i + @days = 30 unless [1, 7, 30, 90].include?(@days) @reports = EndOfDayAnalyticsReport.order('day DESC') end + def hub + @sidekiq_stats = Sidekiq::Stats.new + @basil_queue_count = BasilCommission.where(completed_at: nil).count + @basil_today_count = BasilCommission.where('completed_at >= ?', Time.current.beginning_of_day).count + + # Words written stats (aggregate across all users) + @words_written_today = calculate_words_written_for_date(Date.current) + @words_written_this_week = calculate_words_written_in_range(Date.current.beginning_of_week..Date.current) + end + def content_type type_whitelist = Rails.application.config.content_types[:all].map(&:name) @@ -52,18 +61,189 @@ def reported_shares reported_share_ids = ContentPageShareReport.where(approved_at: nil).pluck(:content_page_share_id) @feed = ContentPageShare.where(id: reported_share_ids) .order('created_at DESC') - .includes([:content_page, :user, :share_comments]) - .limit(100) + .includes([:content_page, :user, :share_comments, content_page_share_reports: :user]) + .paginate(page: params[:page], per_page: 100) + end + + def destroy_share + share = ContentPageShare.find(params[:id]) + share.destroy + redirect_to '/admin/shares/reported', notice: 'Share deleted successfully.' + end + + def destroy_user + user = User.find(params[:id]) + user.destroy + redirect_to '/admin/shares/reported', notice: 'User and all their content deleted.' + end + + def dismiss_share_reports + share = ContentPageShare.find(params[:id]) + share.content_page_share_reports.update_all(approved_at: Time.current) + redirect_to '/admin/shares/reported', notice: 'Reports dismissed.' end def churn + @timespan = params[:timespan] + @timespan = nil unless %w[30 90 365].include?(@timespan) + + @start_date = @timespan.present? ? @timespan.to_i.days.ago.to_date : nil + end + + def attributes + @total_attributes = AttributeField.count + @total_users = AttributeField.distinct.count(:user_id) + @avg_per_user = @total_users > 0 ? (@total_attributes.to_f / @total_users).round(1) : 0 + + @by_content_type = AttributeCategory + .joins(:attribute_fields) + .group(:entity_type) + .count end def notifications - @clicked_notifications = Notification.where.not(viewed_at: nil) - @notifications = Notification.all.order(:reference_code) + # Pre-aggregate stats by reference_code in a single query (top 50 by volume) + @code_stats = Notification + .select( + 'reference_code', + 'COUNT(*) as total_count', + 'COUNT(viewed_at) as clicked_count' + ) + .group(:reference_code) + .order('total_count DESC') + .limit(50) - @codes = Notification.distinct.order('reference_code').pluck(:reference_code) + # Get list of codes for search autocomplete (use top 50 codes already fetched, not all codes) + @codes = @code_stats.map(&:reference_code).compact + + # Overall stats using database aggregation + @total_count = Notification.count + @clicked_count = Notification.where.not(viewed_at: nil).count + + # Time-to-click stats calculated in SQL (avoid loading all rows into memory) + # Use database-specific syntax for date diff calculation + if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' + @avg_seconds = Notification + .where.not(viewed_at: nil) + .where.not(happened_at: nil) + .average('EXTRACT(EPOCH FROM (viewed_at - happened_at))') + &.to_i + else + # SQLite: use strftime to calculate seconds + @avg_seconds = Notification + .where.not(viewed_at: nil) + .where.not(happened_at: nil) + .average("(julianday(viewed_at) - julianday(happened_at)) * 86400") + &.to_i + end + + # Pre-aggregate link stats (top 50 by volume) + @link_stats = Notification + .where.not(passthrough_link: [nil, '']) + .select( + 'passthrough_link', + 'COUNT(*) as total_count', + 'COUNT(viewed_at) as clicked_count' + ) + .group(:passthrough_link) + .order('total_count DESC') + .limit(50) + + # Pre-calculate chart data (last 12 months) - avoid running in view + twelve_months_ago = 12.months.ago + @sent_by_month = Notification + .where('created_at > ?', twelve_months_ago) + .group_by_month(:created_at) + .count + + @clicked_by_month = Notification + .where.not(viewed_at: nil) + .where('created_at > ?', twelve_months_ago) + .group_by_month(:created_at) + .count + end + + def notification_reference + @reference_code = params[:reference_code] + @notifications = Notification.where(reference_code: @reference_code) + + # Basic counts (all done in SQL) + @total_sent = @notifications.count + @total_clicked = @notifications.where.not(viewed_at: nil).count + @click_rate = @total_sent > 0 ? (@total_clicked / @total_sent.to_f * 100).round(1) : 0 + @unique_users = @notifications.distinct.count(:user_id) + + # Time to click calculations - done entirely in SQL (avoid loading all rows into Ruby) + clicked_with_times = @notifications.where.not(viewed_at: nil).where.not(happened_at: nil) + + if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' + # PostgreSQL: use EXTRACT and PERCENTILE_CONT for all stats in one query + time_stats = clicked_with_times.select( + 'COUNT(*) as stat_count', + 'AVG(EXTRACT(EPOCH FROM (viewed_at - happened_at)))::integer as avg_seconds', + 'MIN(EXTRACT(EPOCH FROM (viewed_at - happened_at)))::integer as min_seconds', + 'MAX(EXTRACT(EPOCH FROM (viewed_at - happened_at)))::integer as max_seconds', + 'PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (viewed_at - happened_at)))::integer as median_seconds' + ).take + + if time_stats && time_stats.stat_count.to_i > 0 + @avg_seconds = time_stats.avg_seconds + @median_seconds = time_stats.median_seconds + @fastest_seconds = time_stats.min_seconds + @slowest_seconds = time_stats.max_seconds + end + else + # SQLite: no PERCENTILE_CONT, calculate separately + @avg_seconds = clicked_with_times + .average("(julianday(viewed_at) - julianday(happened_at)) * 86400") + &.to_i + + @fastest_seconds = clicked_with_times + .minimum("(julianday(viewed_at) - julianday(happened_at)) * 86400") + &.to_i + + @slowest_seconds = clicked_with_times + .maximum("(julianday(viewed_at) - julianday(happened_at)) * 86400") + &.to_i + + # For median in SQLite, we need a subquery approach (approximation using LIMIT/OFFSET) + total_clicked_with_times = clicked_with_times.count + if total_clicked_with_times > 0 + median_row = clicked_with_times + .select("(julianday(viewed_at) - julianday(happened_at)) * 86400 as diff_seconds") + .order('diff_seconds') + .offset(total_clicked_with_times / 2) + .limit(1) + .first + @median_seconds = median_row&.diff_seconds&.to_i + end + end + + # Date range (single row queries with index) + @first_notification = @notifications.order(:created_at).limit(1).first + @last_notification = @notifications.order(created_at: :desc).limit(1).first + + # Sample message + @sample_notification = @notifications.where.not(message_html: [nil, '']).order(created_at: :desc).limit(1).first + + # Pre-aggregate link stats for this reference code (avoid N+1 in view) + @link_stats = @notifications + .where.not(passthrough_link: [nil, '']) + .select( + 'passthrough_link', + 'COUNT(*) as total_count', + 'COUNT(viewed_at) as clicked_count' + ) + .group(:passthrough_link) + .order('total_count DESC') + .limit(50) + + # Pre-calculate chart data (avoid running queries in view) + @sent_by_month = @notifications.group_by_month(:created_at).count + @clicked_by_month = @notifications.where.not(viewed_at: nil).group_by_month(:created_at).count + + # Flag for whether we have time stats to display + @has_time_stats = @avg_seconds.present? end def hate @@ -73,7 +253,7 @@ def hate def spam @posts = Thredded::PrivatePost - .where('content ILIKE ?', "%http%") + .where(Thredded::PrivatePost.arel_table[:content].matches('%http%')) .order('id DESC') .limit(params.fetch(:limit, 500)) .includes(:postable) @@ -97,4 +277,78 @@ def perform_unsubscribe def promos @codes = PageUnlockPromoCode.all.includes(:promotions) end + + private + + # Calculate total words written across all users for a specific date + # Uses delta calculation: today's count - previous day's count for each entity + def calculate_words_written_for_date(target_date) + # Get all records for the target date grouped by entity + today_totals = WordCountUpdate.where(for_date: target_date) + .group(:entity_type, :entity_id) + .maximum(:word_count) + + return 0 if today_totals.empty? + + # Get the previous record for each entity (day before target_date or earlier) + prev_totals = {} + today_totals.keys.each do |entity_key| + entity_type, entity_id = entity_key + prev_record = WordCountUpdate + .where(entity_type: entity_type, entity_id: entity_id) + .where('for_date < ?', target_date) + .order(for_date: :desc) + .limit(1) + .pluck(:word_count) + .first + prev_totals[entity_key] = prev_record || 0 + end + + # Calculate deltas (only count positive deltas) + total_delta = 0 + today_totals.each do |entity_key, today_count| + prev_count = prev_totals[entity_key] || 0 + delta = today_count - prev_count + total_delta += delta if delta > 0 + end + + total_delta + end + + # Calculate total words written across all users for a date range + def calculate_words_written_in_range(date_range) + start_date = date_range.first + end_date = date_range.last + + # Get the latest word count for each entity within the range + latest_in_range = WordCountUpdate.where(for_date: date_range) + .group(:entity_type, :entity_id) + .maximum(:word_count) + + return 0 if latest_in_range.empty? + + # Get the previous record for each entity (before range started) + prev_totals = {} + latest_in_range.keys.each do |entity_key| + entity_type, entity_id = entity_key + prev_record = WordCountUpdate + .where(entity_type: entity_type, entity_id: entity_id) + .where('for_date < ?', start_date) + .order(for_date: :desc) + .limit(1) + .pluck(:word_count) + .first + prev_totals[entity_key] = prev_record || 0 + end + + # Calculate deltas + total_delta = 0 + latest_in_range.each do |entity_key, current_count| + prev_count = prev_totals[entity_key] || 0 + delta = current_count - prev_count + total_delta += delta if delta > 0 + end + + total_delta + end end diff --git a/app/controllers/api/v1/attribute_categories_controller.rb b/app/controllers/api/v1/attribute_categories_controller.rb index e344c932c..946196e46 100644 --- a/app/controllers/api/v1/attribute_categories_controller.rb +++ b/app/controllers/api/v1/attribute_categories_controller.rb @@ -1,20 +1,41 @@ module Api module V1 class AttributeCategoriesController < ApiController + before_action :authenticate_user!, only: [:edit] + before_action :set_category, only: [:edit] + def suggest - suggestions = AttributeCategorySuggestion.where(entity_type: params.fetch(:entity_type, '').downcase) + # Handle both :content_type (from /api/v1/categories/suggest/:content_type) + # and :entity_type (from other routes) for backward compatibility + entity_type = params.fetch(:content_type, params.fetch(:entity_type, '')).downcase + + suggestions = AttributeCategorySuggestion.where(entity_type: entity_type) .where.not(suggestion: AttributeCategorySuggestion::BLACKLISTED_LABELS) .order('weight desc') .limit(AttributeCategorySuggestion::SUGGESTIONS_RESULT_COUNT) .pluck(:suggestion) if suggestions.empty? - CacheMostUsedAttributeCategoriesJob.perform_later( - params.fetch(:entity_type, nil) - ) + CacheMostUsedAttributeCategoriesJob.perform_later(entity_type) end - render json: suggestions.to_json + render json: suggestions + end + + def edit + authorize_action_for @category + content_type_class = @category.entity_type.constantize + render partial: 'content/attributes/tailwind/category_config', locals: { + category: @category, + content_type_class: content_type_class, + content_type: @category.entity_type.downcase + } + end + + private + + def set_category + @category = AttributeCategory.find(params[:id]) end end end diff --git a/app/controllers/api/v1/attribute_fields_controller.rb b/app/controllers/api/v1/attribute_fields_controller.rb index 2c029fcdd..bba2d0711 100644 --- a/app/controllers/api/v1/attribute_fields_controller.rb +++ b/app/controllers/api/v1/attribute_fields_controller.rb @@ -1,22 +1,43 @@ module Api module V1 class AttributeFieldsController < ApiController + before_action :authenticate_user!, only: [:edit] + before_action :set_field, only: [:edit] + def suggest + # Handle both :content_type (from /api/v1/fields/suggest/:content_type/:category) + # and :entity_type (from other routes) for backward compatibility + entity_type = params.fetch(:content_type, params.fetch(:entity_type, '')).downcase + category = params.fetch(:category, '') + suggestions = AttributeFieldSuggestion.where( - entity_type: params.fetch(:entity_type, '').downcase, - category_label: params.fetch(:category, '') + entity_type: entity_type, + category_label: category ).where.not(suggestion: [nil, ""]).order('weight desc').limit( AttributeFieldSuggestion::SUGGESTIONS_RESULT_COUNT ).pluck(:suggestion).uniq if suggestions.empty? - CacheMostUsedAttributeFieldsJob.perform_later( - params.fetch(:entity_type, nil), - params.fetch(:category, nil) - ) + CacheMostUsedAttributeFieldsJob.perform_later(entity_type, category) end - render json: suggestions.to_json + render json: suggestions + end + + def edit + authorize_action_for @field + content_type_class = @field.attribute_category.entity_type.constantize + render partial: 'content/attributes/tailwind/field_config', locals: { + field: @field, + content_type_class: content_type_class, + content_type: @field.attribute_category.entity_type.downcase + } + end + + private + + def set_field + @field = AttributeField.find(params[:id]) end end end diff --git a/app/controllers/api/v1/attributes_controller.rb b/app/controllers/api/v1/attributes_controller.rb index c7432a13f..aa70cb3d2 100644 --- a/app/controllers/api/v1/attributes_controller.rb +++ b/app/controllers/api/v1/attributes_controller.rb @@ -1,9 +1,116 @@ module Api module V1 class AttributesController < ApiController - def suggest - raise "NotImplementedYet".inspect + before_action :authenticate_user! + + # Category endpoints + def category_edit + @category = AttributeCategory.find(params[:id]) + authorize_action_for @category + + @content_type_class = @category.entity_type.constantize + + render partial: 'content/attributes/tailwind/category_config', locals: { + category: @category, + content_type_class: @content_type_class, + content_type: @category.entity_type.downcase + } + end + + # Field endpoints + def field_edit + @field = AttributeField.find(params[:id]) + authorize_action_for @field + + @category = @field.attribute_category + @content_type_class = @category.entity_type.constantize + render partial: 'content/attributes/tailwind/field_config', locals: { + field: @field, + content_type_class: @content_type_class, + content_type: @category.entity_type.downcase + } + end + + # Sort endpoint (handles both categories and fields) + def sort + sortable_class = params[:sortable_class] + content_id = params[:content_id] + intended_position = params[:intended_position].to_i + + if sortable_class == 'AttributeCategory' + category = AttributeCategory.find(content_id) + authorize_action_for category + + # Update category position + category.update(position: intended_position) + + # Update other categories' positions + categories = category.user.attribute_categories + .where(entity_type: category.entity_type) + .where.not(id: category.id) + .order(:position) + + # Reposition categories + position = 0 + categories.each do |c| + position += 1 + position += 1 if position == intended_position + c.update_columns(position: position) + end + + render json: { success: true } + elsif sortable_class == 'AttributeField' + field = AttributeField.find(content_id) + authorize_action_for field + + # Check if field is moving to a different category + if params[:attribute_category_id].present? && + field.attribute_category_id.to_s != params[:attribute_category_id].to_s + + # Update field's category + new_category = AttributeCategory.find(params[:attribute_category_id]) + authorize_action_for new_category + + field.update(attribute_category_id: new_category.id, position: intended_position) + + # Update positions in the new category + fields = new_category.attribute_fields + .where.not(id: field.id) + .order(:position) + + position = 0 + fields.each do |f| + position += 1 + position += 1 if position == intended_position + f.update_columns(position: position) + end + else + # Just update position within current category + field.update(position: intended_position) + + # Update other fields' positions + fields = field.attribute_category.attribute_fields + .where.not(id: field.id) + .order(:position) + + position = 0 + fields.each do |f| + position += 1 + position += 1 if position == intended_position + f.update_columns(position: position) + end + end + + render json: { success: true } + else + render json: { error: "Invalid sortable class" }, status: :unprocessable_entity + end + end + + # API suggestion endpoint + def suggest + # For compatibility with existing code entity_type = params[:entity_type] field_label = params[:field_label] return unless Rails.application.config.content_types[:all].map(&:name).include?(entity_type) @@ -12,17 +119,11 @@ def suggest label: field_label, field_type: 'text_area' ).pluck(:id) + + # Return sample suggestions for now + suggestions = ["Example 1", "Example 2", "Example 3"] - # This is too slow. Need DB indexes? - # suggestions = Attribute.where(attribute_field_id: field_ids, entity_type: entity_type) - # .where.not(value: [nil, ""]) - # .group(:value) - # .order('count_id DESC') - # .limit(50) - # .count(:id) - # .reject { |_, count| count < 1 } - - render json: suggestions.to_json + render json: suggestions end end end diff --git a/app/controllers/api/v1/content_controller.rb b/app/controllers/api/v1/content_controller.rb new file mode 100644 index 000000000..12061b78d --- /dev/null +++ b/app/controllers/api/v1/content_controller.rb @@ -0,0 +1,37 @@ +module Api + module V1 + class ContentController < ApiController + before_action :authenticate_user! + + def sort + sortable_class = params[:sortable_class] + content_id = params[:content_id] + intended_position = params[:intended_position].to_i + + if sortable_class == 'AttributeCategory' + category = AttributeCategory.find(content_id) + authorize_action_for category + + # Update category position + category.update(position: intended_position) + + render json: { status: :ok } + elsif sortable_class == 'AttributeField' + field = AttributeField.find(content_id) + authorize_action_for field + + # Update field position + field.update(position: intended_position) + + render json: { status: :ok } + else + render json: { status: :error, message: 'Invalid sortable class' }, status: :unprocessable_entity + end + rescue ActiveRecord::RecordNotFound => e + render json: { status: :error, message: 'Record not found' }, status: :not_found + rescue StandardError => e + render json: { status: :error, message: e.message }, status: :internal_server_error + end + end + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/page_names_controller.rb b/app/controllers/api/v1/page_names_controller.rb new file mode 100644 index 000000000..1ecc97a6a --- /dev/null +++ b/app/controllers/api/v1/page_names_controller.rb @@ -0,0 +1,51 @@ +module Api + module V1 + class PageNamesController < ApplicationController + skip_before_action :verify_authenticity_token + before_action :authenticate_user! + + def show + page_type = params[:type] + page_id = params[:id] + + return render json: { error: 'Missing type or id parameter' }, status: 400 unless page_type.present? && page_id.present? + + # Validate page type + unless Rails.application.config.content_type_names[:all].include?(page_type) || + ['Document', 'Timeline'].include?(page_type) + return render json: { error: 'Invalid page type' }, status: 400 + end + + # Find the page + begin + klass = page_type.constantize + page = klass.find_by(id: page_id) + + # Check if page exists and user has access + if page.nil? + return render json: { error: 'Page not found' }, status: 404 + end + + unless page.readable_by?(current_user) + return render json: { error: 'Access denied' }, status: 403 + end + + # Get the page name + page_name = if page.respond_to?(:label) && page.label.present? + page.label + elsif page.respond_to?(:name) && page.name.present? + page.name + elsif page.respond_to?(:title) && page.title.present? + page.title + else + "Unnamed #{page_type}" + end + + render json: { name: page_name } + rescue => e + render json: { error: "Error loading page name: #{e.message}" }, status: 500 + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 6e61a6e4c..8d7ea0586 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -30,7 +30,7 @@ def require_admin_access def set_metadata @page_title ||= '' - @page_keywords ||= %w[writing author nanowrimo novel character fiction fantasy universe creative dnd roleplay game design] + @page_keywords ||= %w[writing author novel character fiction fantasy universe creative dnd roleplay game design] @page_description ||= 'Notebook.ai is a set of tools for writers and roleplayers to create magnificent universes — and everything within them.' end @@ -43,6 +43,16 @@ def set_universe_session found_universe = nil unless found_universe.user_id == current_user.id || found_universe.contributors.pluck(:user_id).include?(current_user.id) session[:universe_id] = found_universe.id if found_universe end + + # Safely intercept and redirect back if requested, preventing Open Redirects + if params[:return_to].present? + is_safe_local_path = params[:return_to].start_with?('/') && !params[:return_to].start_with?('//') + is_safe_absolute_url = params[:return_to].start_with?(request.base_url) + + if is_safe_local_path || is_safe_absolute_url + redirect_to params[:return_to], allow_other_host: false and return + end + end end end @@ -79,6 +89,7 @@ def cache_most_used_page_information cache_current_user_content cache_notifications cache_recently_edited_pages + cache_most_edited_pages end def cache_activated_content_types @@ -113,13 +124,15 @@ def cache_current_user_content @current_user_content[content_type] ||= [] end - # Likewise, we should also always cache Timelines & Documents + # Likewise, we should also always cache Timelines, Documents, & Books if @universe_scope @current_user_content['Timeline'] = current_user.timelines.where(universe_id: @universe_scope.try(:id)).to_a - @current_user_content['Document'] = current_user.documents.where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a + @current_user_content['Document'] = current_user.documents.unarchived.where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a + @current_user_content['Book'] = current_user.books.unarchived.where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a else @current_user_content['Timeline'] = current_user.timelines.to_a - @current_user_content['Document'] = current_user.documents.order('updated_at DESC').to_a + @current_user_content['Document'] = current_user.documents.unarchived.order('updated_at DESC').to_a + @current_user_content['Book'] = current_user.books.unarchived.order('updated_at DESC').to_a end end @@ -157,6 +170,34 @@ def cache_recently_edited_pages(amount=50) end end + def cache_most_edited_pages(amount=50) + cache_current_user_content + + @most_edited_pages ||= if user_signed_in? + # Get all user's content + all_content = @current_user_content.values.flatten + + # Fetch all edit counts in a single query (fixes N+1) + edit_counts = ContentChangeEvent.where(user_id: current_user.id) + .group(:content_type, :content_id) + .count + + # Map content to their edit counts using hash lookup + content_with_edit_counts = all_content.map do |content_page| + edit_count = edit_counts[[content_page.page_type, content_page.id]] || 0 + [content_page, edit_count] + end + + # Sort by edit count (descending) and take the top pages + # Keep both content page and edit count for the view + content_with_edit_counts + .sort_by { |content_page, edit_count| -edit_count } + .first(amount) + else + [] + end + end + def cache_forums_unread_counts @unread_threads ||= if user_signed_in? Thredded::Topic.unread_followed_by(current_user).count @@ -185,11 +226,13 @@ def cache_contributable_universe_ids end def cache_linkable_content_for_each_content_type + return unless user_signed_in? + cache_contributable_universe_ids cache_current_user_content linkable_classes = @activated_content_types - linkable_classes += %w(Document Timeline) + linkable_classes += %w(Document Timeline Book) @linkables_cache = {} # Cache is list of [[page_name, page_id], [page_name, page_id], ...] @linkables_raw = {} # Raw is list of objects [#{page}, #{page}, ...] diff --git a/app/controllers/attribute_categories_controller.rb b/app/controllers/attribute_categories_controller.rb index fa5760ca3..7e3ed729d 100644 --- a/app/controllers/attribute_categories_controller.rb +++ b/app/controllers/attribute_categories_controller.rb @@ -1,19 +1,207 @@ # Controller for the Attribute model class AttributeCategoriesController < ContentController + before_action :authenticate_user! + before_action :set_attribute_category, only: [:edit, :update, :destroy] + + def edit + unless @attribute_category.readable_by?(current_user) + render json: { error: "You don't have permission to edit that!" }, status: :forbidden + return + end + + content_type_class = @attribute_category.entity_type.classify.constantize + render partial: 'content/attributes/tailwind/category_config', locals: { + category: @attribute_category, + content_type_class: content_type_class, + content_type: @attribute_category.entity_type.downcase + } + end + def create - initialize_object.save! - redirect_to( - attribute_customization_path(content_type: @content.entity_type), - notice: "Shiny new #{@content.label} category created!" - ) + if initialize_object.save! + @content = @attribute_category if @attribute_category + @content ||= initialize_object + + message = "Shiny new #{@content.label} category created!" + successful_response(@content, message) + else + failed_response( + 'create', + :unprocessable_entity, + "Unable to create category. Error code: " + (@content&.errors&.to_json || 'Unknown error') + ) + end + end + + def update + unless @attribute_category.updatable_by?(current_user) + flash[:notice] = "You don't have permission to edit that!" + return redirect_back fallback_location: root_path + end + + # Track what actually changed + original_hidden = @attribute_category.hidden + original_position = @attribute_category.position + + if @attribute_category.update(content_params) + @content = @attribute_category + + # Generate specific message based on what was actually updated + message = if @attribute_category.hidden != original_hidden + if @attribute_category.hidden? + "#{@attribute_category.label} category is now hidden" + else + "#{@attribute_category.label} category is now visible" + end + elsif @attribute_category.position != original_position + "#{@attribute_category.label} category moved to position #{@attribute_category.position}" + else + "#{@attribute_category.label} category saved successfully!" + end + + successful_response(@attribute_category, message) + else + failed_response( + 'edit', + :unprocessable_entity, + "Unable to save category. Error code: " + @attribute_category.errors.to_json + ) + end + end + + def destroy + unless @attribute_category.deletable_by?(current_user) + respond_to do |format| + format.html { + flash[:notice] = "You don't have permission to delete that!" + redirect_back fallback_location: root_path + } + format.json { render json: { error: "You don't have permission to delete that!" }, status: :forbidden } + end + return + end + + category_id = @attribute_category.id + category_label = @attribute_category.label + category_entity_type = @attribute_category.entity_type + field_count = @attribute_category.attribute_fields.count + + # Delete the category (this will cascade delete all fields and their answers) + @attribute_category.destroy + + respond_to do |format| + format.html { + redirect_to( + attribute_customization_tailwind_path(content_type: category_entity_type), + notice: "#{category_label} category deleted!" + ) + } + format.json { + render json: { + success: true, + message: "#{category_label} category and its #{field_count} #{'field'.pluralize(field_count)} have been permanently deleted.", + category_id: category_id + }, status: :ok + } + end + end + + def sort + content_id = params[:content_id] + intended_position = params[:intended_position].to_i + + category = current_user.attribute_categories.find_by(id: content_id) + + unless category + render json: { error: "Category not found" }, status: :not_found + return + end + + unless category.updatable_by?(current_user) + render json: { error: "You don't have permission to reorder that category" }, status: :forbidden + return + end + + # Use acts_as_list to move to the intended position + category.insert_at(intended_position + 1) # acts_as_list is 1-indexed + + render json: { + success: true, + message: "#{category.label} category moved to position #{intended_position + 1}", + category: { + id: category.id, + position: category.position, + label: category.label + } + }, status: :ok + end + + def suggest + entity_type = params.fetch(:content_type, '').downcase + + suggestions = AttributeCategorySuggestion.where(entity_type: entity_type) + .where.not(suggestion: AttributeCategorySuggestion::BLACKLISTED_LABELS) + .order('weight desc') + .limit(AttributeCategorySuggestion::SUGGESTIONS_RESULT_COUNT) + .pluck(:suggestion) + + if suggestions.empty? + CacheMostUsedAttributeCategoriesJob.perform_later(entity_type) + end + + render json: suggestions end private - def successful_response(url, notice) + def failed_response(action, status, message) respond_to do |format| - format.html { redirect_to attribute_customization_path(content_type: @content.entity_type), notice: notice } - format.json { render json: @content || {}, status: :success, notice: notice } + format.html { redirect_back fallback_location: root_path, alert: message } + format.json { render json: { error: message }, status: status } + end + end + + def successful_response(record, notice) + respond_to do |format| + format.html { + redirect_to attribute_customization_tailwind_path(content_type: record.entity_type), notice: notice + } + format.json { + response_data = { + success: true, + message: notice, + category: { + id: record.id, + hidden: record.hidden, + label: record.label, + icon: record.icon, + entity_type: record.entity_type, + position: record.position + } + } + + # For new category creation, include rendered HTML + if action_name == 'create' + content_type_class = record.entity_type.classify.constantize + response_data[:rendered_html] = render_to_string( + partial: 'content/attributes/tailwind/category_card', + formats: [:html], + locals: { + category: record, + content_type_class: content_type_class, + content_type: record.entity_type.downcase + } + ) + end + + render json: response_data, status: :ok + } + end + end + + def initialize_object + @content = current_user.attribute_categories.find_or_initialize_by(content_params.except(:field_options)).tap do |category| + category.user_id = current_user.id end end @@ -25,7 +213,11 @@ def content_param_list [ :user_id, :entity_type, :name, :label, :icon, :description, - :hidden + :hidden, :position ] end + + def set_attribute_category + @attribute_category = current_user.attribute_categories.find(params[:id]) + end end diff --git a/app/controllers/attribute_fields_controller.rb b/app/controllers/attribute_fields_controller.rb index bd717aa38..6bae07782 100644 --- a/app/controllers/attribute_fields_controller.rb +++ b/app/controllers/attribute_fields_controller.rb @@ -1,23 +1,72 @@ class AttributeFieldsController < ContentController before_action :authenticate_user! - before_action :set_attribute_field, only: [:update] + before_action :set_attribute_field, only: [:update, :edit, :destroy] def create - initialize_object.save! - - redirect_back( - fallback_location: attribute_customization_path(content_type: @content.attribute_category.entity_type), - notice: "Nifty new #{@content.label} field created!" - ) + if initialize_object.save! + @content = @attribute_field if @attribute_field + @content ||= initialize_object + + message = "Nifty new #{@content.label} field created!" + successful_response(@content, message) + else + failed_response( + 'create', + :unprocessable_entity, + "Unable to create field. Error code: " + (@content&.errors&.to_json || 'Unknown error') + ) + end end def destroy - # Delete this field as usual -- sets @content - super + unless @attribute_field.deletable_by?(current_user) + respond_to do |format| + format.html { redirect_back fallback_location: root_path, alert: "You don't have permission to delete that!" } + format.json { render json: { success: false, error: "You don't have permission to delete that!" }, status: :forbidden } + end + return + end + # Store references before deletion + field_label = @attribute_field.label + related_category = @attribute_field.attribute_category + + # Delete the field + @attribute_field.destroy + # If the related category is now empty, delete it as well - related_category = @content.attribute_category - related_category.destroy if related_category.attribute_fields.empty? + if related_category.attribute_fields.empty? + related_category.destroy + end + + # Respond with success + respond_to do |format| + format.html { + redirect_to attribute_customization_tailwind_path(content_type: related_category.entity_type), + notice: "#{field_label} field deleted successfully" + } + format.json { + render json: { + success: true, + message: "#{field_label} field deleted successfully", + deleted_field_id: params[:id] + }, status: :ok + } + end + end + + def edit + unless @attribute_field.readable_by?(current_user) + render json: { error: "You don't have permission to edit that!" }, status: :forbidden + return + end + + content_type_class = @attribute_field.attribute_category.entity_type.classify.constantize + render partial: 'content/attributes/tailwind/field_config', locals: { + field: @attribute_field, + content_type_class: content_type_class, + content_type: @attribute_field.attribute_category.entity_type.downcase + } end def update @@ -26,12 +75,34 @@ def update return redirect_back fallback_location: root_path end - if @attribute_field.update(content_params.merge({ migrated_from_legacy: true })) + # Clean up field_options to handle empty linkable_types arrays properly + cleaned_params = content_params.dup + if cleaned_params[:field_options] && cleaned_params[:field_options][:linkable_types] + # Remove empty strings from linkable_types array + cleaned_params[:field_options][:linkable_types] = cleaned_params[:field_options][:linkable_types].reject(&:blank?) + end + + # Track what actually changed + original_hidden = @attribute_field.hidden + original_position = @attribute_field.position + + if @attribute_field.update(cleaned_params.merge({ migrated_from_legacy: true })) @content = @attribute_field - successful_response( - @attribute_field, - t(:update_success, model_name: @attribute_field.label) - ) + + # Generate specific message based on what was actually updated + message = if @attribute_field.hidden != original_hidden + if @attribute_field.hidden? + "#{@attribute_field.label} field is now hidden" + else + "#{@attribute_field.label} field is now visible" + end + elsif @attribute_field.position != original_position + "#{@attribute_field.label} field moved to position #{@attribute_field.position}" + else + "#{@attribute_field.label} field saved successfully!" + end + + successful_response(@attribute_field, message) else failed_response( 'edit', @@ -41,16 +112,81 @@ def update end end + def sort + content_id = params[:content_id] + intended_position = params[:intended_position].to_i + attribute_category_id = params[:attribute_category_id] + + field = current_user.attribute_fields.find_by(id: content_id) + + unless field + render json: { error: "Field not found" }, status: :not_found + return + end + + unless field.updatable_by?(current_user) + render json: { error: "You don't have permission to reorder that field" }, status: :forbidden + return + end + + # If moving to a different category, update the category first + if attribute_category_id && field.attribute_category_id.to_s != attribute_category_id.to_s + new_category = current_user.attribute_categories.find_by(id: attribute_category_id) + if new_category + field.update(attribute_category_id: new_category.id) + end + end + + # Use acts_as_list to move to the intended position within the category + field.insert_at(intended_position + 1) # acts_as_list is 1-indexed + + render json: { + success: true, + message: "#{field.label} field moved to position #{intended_position + 1}", + field: { + id: field.id, + position: field.position, + label: field.label, + attribute_category_id: field.attribute_category_id + } + }, status: :ok + end + + def suggest + entity_type = params.fetch(:content_type, '').downcase + category = params.fetch(:category, '') + + suggestions = AttributeFieldSuggestion.where( + entity_type: entity_type, + category_label: category + ).where.not(suggestion: [nil, ""]).order('weight desc').limit( + AttributeFieldSuggestion::SUGGESTIONS_RESULT_COUNT + ).pluck(:suggestion).uniq + + if suggestions.empty? + CacheMostUsedAttributeFieldsJob.perform_later(entity_type, category) + end + + render json: suggestions + end + private def initialize_object - @content = AttributeField.find_or_initialize_by(content_params.except(:field_options)).tap do |field| + @attribute_field = AttributeField.find_or_initialize_by(content_params.except(:field_options)).tap do |field| field.user_id = current_user.id - field.field_options = content_params.fetch(:field_options, {}) + + # Clean up field_options to handle empty linkable_types arrays properly + field_options = content_params.fetch(:field_options, {}) + if field_options[:linkable_types] + field_options[:linkable_types] = field_options[:linkable_types].reject(&:blank?) + end + field.field_options = field_options + field.migrated_from_legacy = true end - if @content.attribute_category_id.nil? + if @attribute_field.attribute_category_id.nil? category = current_user.attribute_categories.where(id: content_params[:attribute_category_id]).first if category.nil? @@ -60,10 +196,10 @@ def initialize_object end end - @content.attribute_category_id = category.id + @attribute_field.attribute_category_id = category.id end - @content + @attribute_field end def content_deletion_redirect_url @@ -73,16 +209,63 @@ def content_deletion_redirect_url def content_creation_redirect_url if @content.present? category = @content.attribute_category - attribute_customization_path(content_type: category.entity_type) + attribute_customization_tailwind_path(content_type: category.entity_type) else :back end end - def successful_response(url, notice) + def successful_response(record, notice) + respond_to do |format| + format.html { + redirect_to attribute_customization_tailwind_path(content_type: record.attribute_category.entity_type), notice: notice + } + format.json { + # Get the content type class for the partial + content_type_class = record.attribute_category.entity_type.classify.constantize + + # Create a temporary HTML response context to render the partial + field_html = nil + begin + # Force the lookup context to use HTML templates + old_formats = lookup_context.formats + lookup_context.formats = [:html] + + field_html = render_to_string( + partial: 'content/attributes/tailwind/field_item', + locals: { + field: record, + content_type_class: content_type_class, + content_type: record.attribute_category.entity_type.downcase + } + ) + ensure + # Restore original formats + lookup_context.formats = old_formats + end + + response_data = { + success: true, + message: notice, + field: { + id: record.id, + hidden: record.hidden, + label: record.label, + field_type: record.field_type, + attribute_category_id: record.attribute_category_id, + position: record.position + }, + html: field_html + } + render json: response_data, status: :ok + } + end + end + + def failed_response(action, status, message) respond_to do |format| - format.html { redirect_to attribute_customization_path(content_type: @content.attribute_category.entity_type), notice: notice } - format.json { render json: @content || {}, status: :success, notice: notice } + format.html { redirect_back fallback_location: root_path, alert: message } + format.json { render json: { success: false, error: message }, status: status } end end @@ -100,10 +283,14 @@ def content_param_list :attribute_category, :name, :field_type, :label, :description, - :entity_type, + :entity_type, :attribute_category_id, - :hidden, - field_options: {} + :hidden, :position, + field_options: [ + :display_style, + :input_size, + linkable_types: [] + ] ] end diff --git a/app/controllers/basil_controller.rb b/app/controllers/basil_controller.rb index d144fb68b..af4d40b6e 100644 --- a/app/controllers/basil_controller.rb +++ b/app/controllers/basil_controller.rb @@ -1,5 +1,6 @@ class BasilController < ApplicationController before_action :authenticate_user!, except: [:complete_commission, :about, :stats, :jam, :queue_jam_job, :commission_info] + before_action :cache_basil_user_content, if: :user_signed_in? before_action :require_admin_access, only: [:review], unless: -> { Rails.env.development? } @@ -23,6 +24,13 @@ def index def content # Fetch the content page from our already-queried cache of current user content @content_type = params[:content_type].humanize + + # Debug: Let's see what's actually in the cache + Rails.logger.debug "=== BASIL DEBUG ===" + Rails.logger.debug "Looking for #{@content_type} with ID #{params[:id]}" + Rails.logger.debug "Available content types: #{@current_user_content&.keys}" + Rails.logger.debug "#{@content_type} content: #{@current_user_content[@content_type]&.map { |p| "#{p.class.name}##{p.id}:#{p.name}" }}" + @content = @current_user_content.fetch(@content_type, []).detect do |page| page.id == params[:id].to_i end @@ -218,6 +226,113 @@ def content exclude_field_ids: included_field_ids ) + # Identify suggested fields for helpful empty state guidance + @suggested_fields = [] + if @relevant_fields.empty? + case @content_type + when 'Character' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Role', 'Overview section'], + ['Age', 'Overview section'], + ['Gender', 'Overview section'], + ['Looks fields', 'Looks section (hair color, eye color, etc.)'] + ] + when 'Location' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Type', 'Overview section'], + ['Description', 'Overview section'], + ['Area or Climate', 'Geography section'] + ] + when 'Item' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Item Type', 'Overview section'], + ['Description', 'Overview section'], + ['Appearance details', 'Looks or Appearance section'], + ['Magical effects', 'Abilities section (optional)'] + ] + when 'Building' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Type of building', 'Overview section'], + ['Description', 'Overview section'], + ['Design details', 'Design section'] + ] + when 'Creature' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Description', 'Overview section'], + ['Physical details', 'Looks section'] + ] + when 'Flora' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Description', 'Overview section'], + ['Appearance details', 'Looks section'] + ] + when 'Food' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Type of food', 'Overview section'], + ['Taste', 'Overview section'], + ['Description', 'Overview section'] + ] + when 'Landmark' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Type of landmark', 'Overview section'], + ['Description', 'Overview section'], + ['Appearance details', 'Appearance section'] + ] + when 'Planet' + @suggested_fields = [ + ['Geography details', 'Geography section'], + ['Moons', 'Astral section'] + ] + when 'Town' + @suggested_fields = [ + ['Description', 'Overview section'], + ['Layout details', 'Layout section'] + ] + when 'Vehicle' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Type of vehicle', 'Overview section'], + ['Description', 'Overview section'], + ['Appearance details', 'Looks section'] + ] + when 'Deity' + @suggested_fields = [ + ['Name', 'Overview section'], + ['Description', 'Overview section'], + ['Physical form', 'Appearance section'], + ['Symbols', 'Symbols section'] + ] + when 'Technology' + @suggested_fields = [ + ['Description', 'Overview section'], + ['Materials', 'Production section'], + ['Appearance details', 'Appearance section'] + ] + when 'Tradition' + @suggested_fields = [ + ['Type of tradition', 'Overview section'], + ['Description', 'Overview section'], + ['Activities', 'Celebrations section'], + ['Symbolism', 'Celebrations section'] + ] + else + # Generic fallback for other content types + @suggested_fields = [ + ['Name', 'Overview section'], + ['Description', 'Overview section'], + ['Key details', 'Main sections of the page'] + ] + end + end + # Finally, cache some state we can reference in the view @commissions = BasilCommission.where(entity_type: @content.page_type, entity_id: @content.id) .where(saved_at: nil) @@ -325,7 +440,8 @@ def stats .order(:score_adjustment) .group(:score_adjustment) .count - days_since_start = (Date.current - BasilFeedback.minimum(:updated_at).to_date) + earliest_feedback = BasilFeedback.minimum(:updated_at) + days_since_start = earliest_feedback ? (Date.current - earliest_feedback.to_date) : 1 days_since_start = 1 if days_since_start.zero? # no dividing by 0 lol total = @feedback_before_today.values.sum @@ -376,6 +492,15 @@ def stats .average(:score_adjustment) .map { |k, v| [k, v.round(1)] }.to_h + # Today's average rating (convert from -2..+3 scale to 1..5 stars) + today_feedback = BasilFeedback.joins(:basil_commission) + .where(basil_commissions: { basil_version: @version }) + .where('basil_feedbacks.updated_at > ?', 24.hours.ago) + @total_ratings_today = today_feedback.count + raw_average = today_feedback.average(:score_adjustment) + # Map -2..+3 to 1..5: (score + 2) / 5 * 4 + 1 + @average_rating_today = raw_average ? ((raw_average + 2) / 5.0 * 4 + 1).round(1) : nil + # queue size (total commissions - completed commissions) # average time to complete today / this week # commissions per day bar chart @@ -414,7 +539,8 @@ def page_stats .order(:score_adjustment) .group(:score_adjustment) .count - days_since_start = (Date.current - BasilFeedback.minimum(:updated_at).to_date) + earliest_feedback = BasilFeedback.minimum(:updated_at) + days_since_start = earliest_feedback ? (Date.current - earliest_feedback.to_date) : 1 days_since_start = 1 if days_since_start.zero? # no dividing by 0 lol total = @feedback_before_today.values.sum @@ -464,12 +590,17 @@ def page_stats end def review - @recent_commissions = BasilCommission.all.includes(:entity, :user).order('id DESC').limit(100) + @recent_commissions = BasilCommission.all.includes(:entity, :user, image_attachment: :blob) + .order('id DESC') + .paginate(page: params[:page], per_page: 100) @commissions_per_user_id = BasilCommission.with_deleted.where('created_at > ?', 48.hours.ago).group(:user_id).order('count_all DESC').limit(5).count + @top_users_by_id = User.where(id: @commissions_per_user_id.keys).index_by(&:id) @unique_users_generating_count = BasilCommission.with_deleted.where('created_at > ?', 48.hours.ago).group(:user_id).count - @current_queue_items = BasilCommission.where(completed_at: nil).order('created_at ASC') + @current_queue_items = BasilCommission.where(completed_at: nil) + .includes(:entity, :user) + .order('created_at ASC') end def commission @@ -674,6 +805,12 @@ def save id: params[:id], user: current_user ) + + if @commission.nil? + render json: { error: "Commission not found" }, status: :not_found + return + end + @commission.update(saved_at: DateTime.current) render json: { success: true }, status: 200 end @@ -683,12 +820,59 @@ def delete id: params[:id], user: current_user ) + + if @commission.nil? + respond_to do |format| + format.html { redirect_back fallback_location: root_path, alert: 'Commission not found.' } + format.all { render json: { error: "Commission not found" }, status: :not_found } + end + return + end + @commission.destroy! - render json: { success: true }, status: 200 + respond_to do |format| + format.html { redirect_back fallback_location: root_path, notice: 'Image successfully deleted.' } + format.all { render json: { success: true }, status: 200 } + end + end + + def update_commission + @commission = BasilCommission.find_by( + id: params[:id], + user: current_user + ) + + if @commission.nil? + render json: { error: "Commission not found" }, status: :not_found + return + end + + if @commission.update(update_commission_params) + render json: { success: true }, status: 200 + else + render json: { error: @commission.errors.full_messages }, status: :unprocessable_entity + end end private + # Cache user content for Basil without universe filtering + # since Basil should be able to generate images for any user content + def cache_basil_user_content + return if @current_user_content + @current_user_content = {} + return unless user_signed_in? + + # Get all enabled content types for Basil + enabled_types = BasilService::ENABLED_PAGE_TYPES + + # Cache content without universe filtering + @current_user_content = current_user.content( + content_types: enabled_types, + universe_id: nil # No universe filtering for Basil + ) + end + def commission_params params.require(:basil_commission).permit(:style, :entity_type, :entity_id, field: {}) end @@ -696,4 +880,8 @@ def commission_params def jam_params params.require(:commission).permit(:name, :age, features: []) end + + def update_commission_params + params.require(:basil_commission).permit(:notes) + end end diff --git a/app/controllers/books_controller.rb b/app/controllers/books_controller.rb new file mode 100644 index 000000000..12fb09aa2 --- /dev/null +++ b/app/controllers/books_controller.rb @@ -0,0 +1,164 @@ +class BooksController < ApplicationController + before_action :authenticate_user!, except: [:show] + before_action :set_book, except: [:index, :new, :create, :show] + before_action :set_sidenav_expansion + + def index + @books = current_user.books.unarchived.includes(:image_uploads) + @books = @books.where(universe_id: @universe_scope.id) if @universe_scope.present? + @books = @books.order(favorite: :desc, updated_at: :desc) + end + + def show + @book = Book.find_by(id: params[:id]) + + if @book.nil? + return redirect_to root_path, notice: "That book doesn't exist!" + end + + unless (current_user || User.new).can_read?(@book) + return redirect_to root_path, notice: "You don't have permission to view that book." + end + + @book_documents = @book.book_documents.includes(:document).order(position: :asc) + @total_words = @book_documents.sum { |bd| bd.document&.word_count.to_i } + @est_reading_time = (@total_words / 200.0).ceil + end + + def new + attrs = { name: 'Untitled Book' } + attrs[:universe_id] = @universe_scope.id if @universe_scope.present? + + @book = current_user.books.create!(attrs) + respond_to do |format| + format.html { redirect_to edit_book_path(@book) } + format.json { render json: { status: 'ok', book: { id: @book.id, name: @book.name } } } + end + end + + def create + @book = current_user.books.new(book_params) + @book.name = 'Untitled Book' if @book.name.blank? + @book.universe_id ||= @universe_scope.id if @universe_scope.present? + + if @book.save + respond_to do |format| + format.html { redirect_to edit_book_path(@book) } + format.json { render json: { id: @book.id, name: @book.name } } + end + else + respond_to do |format| + format.html { redirect_to books_path, alert: 'Failed to create book.' } + format.json { render json: { errors: @book.errors }, status: :unprocessable_entity } + end + end + end + + def edit + @available_documents = current_user.documents.unarchived.order(:title) + @book_documents = @book.book_documents.includes(:document).order(position: :asc) + end + + def update + if @book.update(book_params) + respond_to do |format| + format.html { redirect_to edit_book_path(@book), notice: 'Book updated.' } + format.json { render json: { status: 'ok' } } + end + else + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: { errors: @book.errors }, status: :unprocessable_entity } + end + end + end + + def destroy + @book.destroy + redirect_to books_path, notice: 'Book deleted.' + end + + def toggle_archive + if @book.archived? + @book.unarchive! + respond_to do |format| + format.html { redirect_to edit_book_path(@book) } + format.json { render json: { success: true } } + end + else + @book.archive! + respond_to do |format| + format.html { redirect_to archive_path } + format.json { render json: { success: true } } + end + end + end + + def toggle_favorite + if @book.update(favorite: !@book.favorite) + render json: { success: true, favorite: @book.favorite } + else + render json: { error: "Failed to update favorite status" }, status: :unprocessable_entity + end + end + + # Document management + def add_document + document = current_user.documents.find(params[:document_id]) + unless @book.book_documents.exists?(document: document) + @book.book_documents.create(document: document) + end + + @available_documents = current_user.documents.unarchived.order(:title) + @book_documents = @book.book_documents.includes(:document).order(position: :asc) + + respond_to do |format| + format.js + format.html { redirect_to edit_book_path(@book) } + format.json { render json: { status: 'ok' } } + end + end + + def remove_document + @book.book_documents.find_by(document_id: params[:document_id])&.destroy + + @available_documents = current_user.documents.unarchived.order(:title) + @book_documents = @book.book_documents.includes(:document).order(position: :asc) + + respond_to do |format| + format.js + format.html { redirect_to edit_book_path(@book) } + format.json { render json: { status: 'ok' } } + end + end + + def sort_document + book_document = @book.book_documents.find(params[:book_document_id]) + book_document.insert_at(params[:position].to_i + 1) + render json: { status: 'ok' } + end + + def create_document + document = current_user.documents.create!(title: params[:title].presence || 'Untitled') + @book.book_documents.create!(document: document) + + respond_to do |format| + format.html { redirect_to edit_document_path(document) } + format.json { render json: { status: 'ok', document: { id: document.id, title: document.title } } } + end + end + + private + + def set_book + @book = current_user.books.find(params[:id]) + end + + def set_sidenav_expansion + @sidenav_expansion = 'writing' + end + + def book_params + params.require(:book).permit(:name, :subtitle, :description, :blurb, :status, :privacy, :universe_id, image_uploads_attributes: [:id, :src, :privacy, :_destroy]) + end +end diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb index dc138aa56..b781fb5b2 100644 --- a/app/controllers/content_controller.rb +++ b/app/controllers/content_controller.rb @@ -3,18 +3,16 @@ # TODO: we should probably spin off an Api::ContentController for #api_sort and anything else # api-wise we need class ContentController < ApplicationController - before_action :authenticate_user!, except: [:show, :changelog, :api_sort, :gallery] \ + before_action :authenticate_user!, except: [:show, :changelog, :api_sort] \ + Rails.application.config.content_types[:all_non_universe].map { |type| type.name.downcase.pluralize.to_sym } skip_before_action :cache_most_used_page_information, only: [ :name_field_update, :text_field_update, :tags_field_update, :universe_field_update, :api_sort ] - before_action :migrate_old_style_field_values, only: [:show, :edit] + before_action :cache_linkable_content_for_each_content_type, only: [:new, :show, :edit, :index] - before_action :cache_linkable_content_for_each_content_type, only: [:new, :edit, :index] - - before_action :set_attributes_content_type, only: [:attributes] + before_action :set_attributes_content_type, only: [:attributes, :export_template, :reset_template] before_action :set_navbar_color, except: [:api_sort] before_action :set_navbar_actions, except: [:deleted, :api_sort] @@ -22,6 +20,7 @@ class ContentController < ApplicationController def index @content_type_class = content_type_from_controller(self.class) + @content_type_name = @content_type_class.name pluralized_content_name = @content_type_class.name.downcase.pluralize @page_title = "My #{pluralized_content_name}" @@ -35,13 +34,16 @@ def index @show_scope_notice = @universe_scope.present? && @content_type_class != Universe - # Filters + # For tags, we need all content IDs (not just paginated) to show all available tags + all_content_ids = @content.map(&:id) + @page_tags = PageTag.where( page_type: @content_type_class.name, - page_id: @content.pluck(:id) + page_id: all_content_ids ).order(:tag) + @filtered_page_tags = [] if params.key?(:slug) - @filtered_page_tags = @page_tags.where(slug: params[:slug]) + @filtered_page_tags = @page_tags.where(slug: params[:slug]).uniq(&:slug) @content.select! { |content| @filtered_page_tags.pluck(:page_id).include?(content.id) } end @page_tags = @page_tags.uniq(&:tag) @@ -50,35 +52,68 @@ def index @content.select!(&:favorite?) end - @content = @content.sort_by {|x| [x.favorite? ? 0 : 1, x.name] } - - @questioned_content = @content.sample - @attribute_field_to_question = SerendipitousService.question_for(@questioned_content) - - # Query for both regular and pinned images - image_uploads = ImageUpload.where( - content_type: @content_type_class.name, - content_id: @content.pluck(:id) - ) + # Sort content with favorites always first, then by selected sort option + # Use negative timestamp to sort newest-first without reversing (which would undo favorite ordering) + sort_option = params[:sort] || 'updated_at' + sorted_content = case sort_option + when 'alphabetical' + @content.sort_by { |x| [x.favorite? ? 0 : 1, x.name.downcase] } + when 'created_at' + @content.sort_by { |x| [x.favorite? ? 0 : 1, -x.created_at.to_i] } + else # 'updated_at' or default + @content.sort_by { |x| [x.favorite? ? 0 : 1, -x.updated_at.to_i] } + end - # Group by content but prioritize pinned images - @random_image_including_private_pool_cache = {} - image_uploads.group_by { |image| [image.content_type, image.content_id] }.each do |key, images| - # Check for pinned images first that have a valid src - pinned_image = images.find { |img| img.pinned } - if pinned_image && pinned_image.src_file_name.present? - @random_image_including_private_pool_cache[key] = [pinned_image] - else - # Use all valid images if no valid pinned image - @random_image_including_private_pool_cache[key] = images.select { |img| img.src_file_name.present? } + # Implement pagination manually since @content is an array + @total_content_count = sorted_content.size + page = params[:page] || 1 + per_page = 50 + @current_page = page.to_i + @total_pages = (@total_content_count.to_f / per_page).ceil + + # Calculate pagination slice + start_index = ((@current_page - 1) * per_page) + end_index = start_index + per_page - 1 + @content = sorted_content[start_index..end_index] || [] + @folders = current_user + .folders + .where(context: @content_type_name, parent_folder_id: nil) + .order('title ASC') + + # Only load expensive features if we have content to show + if @content.any? + @questioned_content = @content.sample + @attribute_field_to_question = SerendipitousService.question_for(@questioned_content) + + # Query for both regular and pinned images - only for current page content + current_page_content_ids = @content.map(&:id) + image_uploads = ImageUpload.where( + content_type: @content_type_class.name, + content_id: current_page_content_ids + ) + + # Group by content but prioritize pinned images + @random_image_including_private_pool_cache = {} + image_uploads.group_by { |image| [image.content_type, image.content_id] }.each do |key, images| + # Check for pinned images first that have a valid src + pinned_image = images.find { |img| img.pinned } + if pinned_image && pinned_image.src_file_name.present? + @random_image_including_private_pool_cache[key] = [pinned_image] + else + # Use all valid images if no valid pinned image + @random_image_including_private_pool_cache[key] = images.select { |img| img.src_file_name.present? } + end end - end - @saved_basil_commissions = BasilCommission.where( - entity_type: @content_type_class.name, - entity_id: @content.pluck(:id) - ).where.not(saved_at: nil) - .group_by { |commission| [commission.entity_type, commission.entity_id] } + @saved_basil_commissions = BasilCommission.where( + entity_type: @content_type_class.name, + entity_id: current_page_content_ids + ).where.not(saved_at: nil) + .group_by { |commission| [commission.entity_type, commission.entity_id] } + else + @random_image_including_private_pool_cache = {} + @saved_basil_commissions = {} + end # Uh, do we ever actually make JSON requests to logged-in user pages? respond_to do |format| @@ -87,22 +122,107 @@ def index end end - def show + def show content_type = content_type_from_controller(self.class) return redirect_to(root_path, notice: "That page doesn't exist!", status: :not_found) unless valid_content_types.include?(content_type.name) @content = content_type.find_by(id: params[:id]) return redirect_to(root_path, notice: "You don't have permission to view that content.", status: :not_found) if @content.nil? - return redirect_to(root_path) if @content.user.nil? # deleted user's content + return redirect_to(root_path) if @content.user.nil? # deleted user's content return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email)) - @serialized_content = ContentSerializer.new(@content) - + @serialized_content = ContentSerializer.new(@content, viewing_user: current_user) + # For basil images, assume they're all public for now since there's no privacy column @basil_images = BasilCommission.where(entity: @content) .where.not(saved_at: nil) + if @content.updatable_by?(current_user) + @suggested_page_tags = ( + current_user.page_tags.where(page_type: content_type.name).pluck(:tag) + + PageTagService.suggested_tags_for(content_type.name) + ).uniq + end + + # Calculate counts for "Dive Deeper" navigation items (used in sidebar and content views) + # Gallery count + @gallery_count = 0 + if @content.respond_to?(:image_uploads) + @gallery_count += (current_user && @content.updatable_by?(current_user) ? + @content.image_uploads.count : + @content.image_uploads.where(privacy: 'public').count) rescue 0 + end + @gallery_count += @basil_images.count + + # Associations count + @associations_count = 0 + if @content.respond_to?(:incoming_page_references) + @associations_count += @content.incoming_page_references.count rescue 0 + end + if @content.respond_to?(:document_entities) + @associations_count += @content.document_entities.select(:document_id).distinct.count rescue 0 + end + + # Collections count + @collections_count = 0 + if @content.respond_to?(:page_collections) + @collections_count = @content.page_collections + .joins(:collection) + .where('collections.published = ?', true) + .count rescue 0 + elsif @content.respond_to?(:collections) + @collections_count = @content.collections.count rescue 0 + end + + # Timelines count + @timelines_count = 0 + begin + if defined?(Timeline) && @content.respond_to?(:incoming_page_references) + @timelines_count += @content.incoming_page_references + .where(referencing_page_type: 'Timeline') + .select(:referencing_page_id) + .distinct + .count + end + rescue + # Timeline model might not exist + end + begin + if defined?(TimelineEvent) && @content.respond_to?(:timeline_events) + @timelines_count += @content.timeline_events + .select(:timeline_id) + .distinct + .count + end + rescue + # TimelineEvent model might not exist + end + + # Shares count + @shares_count = 0 + begin + if @content.respond_to?(:content_page_shares) + @shares_count = @content.content_page_shares.count + end + rescue + # ContentPageShare model might not exist + end + + # Universe-specific counts + if @content.is_a?(Universe) + @documents_count = @content.documents.count + @books_count = @content.books.count + + # Universes contain timelines as well as being referenced by them + @timelines_count += @content.timelines.count + + @in_this_universe_count = 0 + Rails.application.config.content_types[:all_non_universe].each do |content_type| + @in_this_universe_count += @content.send(content_type.name.downcase.pluralize).count + end + end + if (current_user || User.new).can_read?(@content) respond_to do |format| format.html { render 'content/show', locals: { content: @content } } @@ -113,6 +233,29 @@ def show end end + + def references + content_type = content_type_from_controller(self.class) + return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.include?(content_type.name) + + @content = content_type.find_by(id: params[:id]) + return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil? + + return redirect_to(root_path) if @content.user.nil? # deleted user's content + return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email)) + + @serialized_content = ContentSerializer.new(@content, viewing_user: current_user) + + analysis_ids = DocumentEntity.where(entity: @content).pluck(:document_analysis_id) + document_ids = DocumentAnalysis.where(id: analysis_ids).pluck(:document_id) + @documents = Document.where(id: document_ids) + @references = @content.incoming_page_references.preload(:referencing_page) + @mentioning_attributes = Attribute.where( + attribute_field_id: @references.pluck(:attribute_field_id), + entity_id: @references.pluck(:referencing_page_id) + ) + end + def new @content = content_type_from_controller(self.class) .new(user: current_user) @@ -165,14 +308,18 @@ def new def edit content_type_class = content_type_from_controller(self.class) - @content = content_type_class.find_by(id: params[:id]) + eager_load_associations = [:user, :page_tags, :image_uploads, :basil_commissions] + eager_load_associations << :universe if content_type_class.reflect_on_association(:universe) + @content = content_type_class + .includes(eager_load_associations) + .find_by(id: params[:id]) if @content.nil? return redirect_to(root_path, notice: "Either this #{content_type_class.name.downcase} doesn't exist, or you don't have access to view it." ) end - @serialized_content = ContentSerializer.new(@content) + @serialized_content = ContentSerializer.new(@content, viewing_user: current_user) @suggested_page_tags = ( current_user.page_tags.where(page_type: content_type_class.name).pluck(:tag) + PageTagService.suggested_tags_for(content_type_class.name) @@ -298,32 +445,37 @@ def toggle_favorite end def toggle_archive - # todo Since this method is triggered via a GET in floating_action_buttons, a malicious user could technically archive - # another user's content if they're able to send that user to a specifically-crafted URL or inject that URL somewhere on - # a page (e.g. img src="/characters/1234/toggle_archive"). Since archiving is reversible this seems fine for release, but - # is something that should be fixed asap before any abuse happens. - content_type = content_type_from_controller(self.class) @content = content_type.with_deleted.find(params[:id]) unless @content.updatable_by?(current_user) - flash[:notice] = "You don't have permission to edit that!" - return redirect_back fallback_location: @content + respond_to do |format| + format.html do + flash[:notice] = "You don't have permission to edit that!" + redirect_back fallback_location: @content + end + format.json { render json: { success: false, error: "Permission denied" }, status: :forbidden } + end + return end - verb = nil - success = if @content.archived? - verb = "unarchived" - @content.unarchive! - else - verb = "archived" - @content.archive! - end + verb = @content.archived? ? "unarchived" : "archived" + success = @content.archived? ? @content.unarchive! : @content.archive! - if success - redirect_back(fallback_location: archive_path, notice: "This page has been #{verb}.") - else - redirect_back(fallback_location: root_path, notice: "Something went wrong while attempting to archive that page.") + respond_to do |format| + if success + format.html do + if verb == "archived" + redirect_to '/data/archive', notice: "This page has been archived." + else + redirect_to @content, notice: "This page has been unarchived." + end + end + format.json { render json: { success: true, archived: @content.archived?, verb: verb } } + else + format.html { redirect_back(fallback_location: root_path, notice: "Something went wrong while attempting to archive that page.") } + format.json { render json: { success: false, error: "Failed to #{verb}" }, status: :unprocessable_entity } + end end end @@ -332,9 +484,29 @@ def changelog return redirect_to root_path unless valid_content_types.include?(content_type.name) @content = content_type.find_by(id: params[:id]) return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil? - @serialized_content = ContentSerializer.new(@content) + @serialized_content = ContentSerializer.new(@content, viewing_user: current_user) return redirect_to(root_path, notice: "You don't have permission to view that content.") unless @content.updatable_by?(current_user || User.new) + # Generate changelog statistics and data + @stats = ChangelogStatsService.new(@content) + @change_intensity = @stats.change_intensity_by_week + + # Get paginated change events first, then group them + page = params[:page] || 1 + per_page = 50 # 50 change events per page + + # Get the base query for change events (without the .last() limit) + change_events_query = ContentChangeEvent.where( + content_id: Attribute.where( + entity_type: @content.class.name, + entity_id: @content.id + ), + content_type: "Attribute" + ).includes(:user).order('created_at DESC') + + @paginated_events = change_events_query.paginate(page: page, per_page: per_page) + @grouped_changes = group_events_by_date(@paginated_events) + if user_signed_in? @navbar_actions << { label: @serialized_content.name, @@ -414,54 +586,63 @@ def attributes @dummy_model = @content_type_class.new end - - def gallery - content_type = content_type_from_controller(self.class) - @content = content_type.find_by(id: params[:id]) - return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil? + + def export_template + service = TemplateExportService.new(current_user, @content_type) - return redirect_to(root_path) if @content.user.nil? # deleted user's content - return if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(@content.user.try(:email)) + case params[:format] + when 'yml', 'yaml' + send_data service.export_as_yaml, + filename: "#{@content_type}_template.yml", + type: 'text/plain' + when 'md', 'markdown' + send_data service.export_as_markdown, + filename: "#{@content_type}_template.md", + type: 'text/plain' + when 'json' + send_data service.export_as_json, + filename: "#{@content_type}_template.json", + type: 'application/json' + when 'csv' + send_data service.export_as_csv, + filename: "#{@content_type}_template.csv", + type: 'text/csv' + else + redirect_back fallback_location: root_path, alert: 'Invalid export format' + end + end + + def reset_template + service = TemplateResetService.new(current_user, @content_type) - if (current_user || User.new).can_read?(@content) - # Serialize content for overview section - @serialized_content = ContentSerializer.new(@content) - - # Get all images for this content with proper ordering - # Only show private images to the owner or contributors - is_owner_or_contributor = false - # Check if the user is the owner or a contributor - if current_user.present? && (@content.user == current_user || - (@content.respond_to?(:universe_id) && - @content.universe_id.present? && - current_user.try(:contributable_universe_ids).to_a.include?(@content.universe_id))) - is_owner_or_contributor = true - @images = ImageUpload.where(content_type: @content.class.name, content_id: @content.id).ordered - else - @images = ImageUpload.where(content_type: @content.class.name, content_id: @content.id, privacy: 'public').ordered - end + if params[:confirm] == 'true' + result = service.reset_template! - # Get additional context information - if @content.is_a?(Universe) - # Universe objects don't have a universe_id field - @universe = nil - @other_content = [] - else - @universe = @content.universe_id.present? ? Universe.find_by(id: @content.universe_id) : nil - @other_content = @content.universe_id.present? ? - content_type.where(universe_id: @content.universe_id).where.not(id: @content.id).limit(5) : [] + respond_to do |format| + format.json do + if result[:success] + render json: { + success: true, + message: result[:message] + }, status: :ok + else + render json: { + success: false, + error: result[:error] + }, status: :unprocessable_entity + end + end end - - # Include basil images too with proper ordering - @basil_images = BasilCommission.where(entity: @content).where.not(saved_at: nil).ordered - - render 'content/gallery' else - return redirect_to root_path, notice: "You don't have permission to view that content." + # Return analysis for confirmation + analysis = service.analyze_reset_impact + render json: analysis, status: :ok end end def toggle_image_pin + # Note: Authentication is handled by before_action :authenticate_user! in the controller + # Find the image based on type and ID if params[:image_type] == 'image_upload' @image = ImageUpload.find_by(id: params[:image_id]) @@ -477,37 +658,55 @@ def toggle_image_pin end # Check permissions - content = params[:image_type] == 'image_upload' ? - @image.content : + content = params[:image_type] == 'image_upload' ? + @image.content : @image.entity - + + # Check if content exists (important for orphaned Basil images) + if content.nil? + return render json: { error: 'Content not found for this image' }, status: 422 + end + # Need to check if user owns or contributes to the content directly - unless content.user_id == current_user.id || - (content.respond_to?(:universe_id) && - content.universe_id.present? && + unless content.user_id == current_user.id || + (content.respond_to?(:universe_id) && + content.universe_id.present? && current_user.contributable_universe_ids.include?(content.universe_id)) return render json: { error: 'Unauthorized' }, status: 403 end - - # Are we pinning or unpinning? - new_pin_status = !@image.pinned + + # Are we pinning or unpinning? Handle nil pinned values explicitly + current_pinned_status = @image.pinned == true + new_pin_status = !current_pinned_status # If we're pinning this image, unpin all other images for this content first # This prevents database locking issues from the model callbacks if new_pin_status == true - # Unpin other image uploads for this content - ImageUpload.where(content_type: content.class.name, content_id: content.id, pinned: true) - .where.not(id: @image.id) - .update_all(pinned: false) - - # Also unpin any basil commissions for this content - BasilCommission.where(entity_type: content.class.name, entity_id: content.id, pinned: true) - .update_all(pinned: false) + if params[:image_type] == 'image_upload' + # Unpinning an ImageUpload, so exclude it from the ImageUpload query + ImageUpload.where(content_type: content.class.name, content_id: content.id, pinned: true) + .where.not(id: @image.id) + .update_all(pinned: false) + # Unpin all basil commissions + BasilCommission.where(entity_type: content.class.name, entity_id: content.id, pinned: true) + .update_all(pinned: false) + else # basil_commission + # Unpin all image uploads + ImageUpload.where(content_type: content.class.name, content_id: content.id, pinned: true) + .update_all(pinned: false) + # Unpinning a BasilCommission, so exclude it from the BasilCommission query + BasilCommission.where(entity_type: content.class.name, entity_id: content.id, pinned: true) + .where.not(id: @image.id) + .update_all(pinned: false) + end end # Now update this image's pin status (without triggering callbacks that cause locks) @image.update_column(:pinned, new_pin_status) - + + # Touch the content so it appears in "recently edited" views + content.touch + # Clear any cached images to ensure pinned images are shown content.instance_variable_set(:@random_image_including_private_cache, nil) content.instance_variable_set(:@pinned_public_image_cache, nil) @@ -554,7 +753,9 @@ def api_sort # Content update for link-type fields def link_field_update @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) - attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) + attribute_value = @attribute_field.attribute_values.find_or_initialize_by( + entity_params.merge(attribute_field_id: @attribute_field.id) + ) attribute_value.user_id ||= current_user.id if params.key?(:attribute_field) @@ -571,32 +772,40 @@ def link_field_update # TODO: move this into a link mention update job valid_reference_ids = [] referenced_page_codes = JSON.parse(attribute_value.value) + + # Preload existing references to avoid N+1 queries in the loop + existing_references = referencing_page.outgoing_page_references + .where(attribute_field_id: @attribute_field.id, reference_type: 'linked') + .index_by { |ref| "#{ref.referenced_page_type}-#{ref.referenced_page_id}" } + referenced_page_codes.each do |page_code| page_type, page_id = page_code.split('-') - reference = referencing_page.outgoing_page_references.find_or_initialize_by( - referenced_page_type: page_type, - referenced_page_id: page_id, - attribute_field_id: @attribute_field.id, - reference_type: 'linked' + reference = existing_references[page_code] || referencing_page.outgoing_page_references.build( + referenced_page_type: page_type, + referenced_page_id: page_id, + attribute_field_id: @attribute_field.id, + reference_type: 'linked' ) reference.cached_relation_title = @attribute_field.label reference.save! - valid_reference_ids << reference.reload.id + valid_reference_ids << reference.id end # Delete all other references still attached to this field, but not present in this request referencing_page.outgoing_page_references .where(attribute_field_id: @attribute_field.id) .where.not(id: valid_reference_ids) - .destroy_all + .delete_all end # Content update for name fields def name_field_update @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) - attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) + attribute_value = @attribute_field.attribute_values.find_or_initialize_by( + entity_params.merge(attribute_field_id: @attribute_field.id) + ) attribute_value.value = field_params.fetch('value', '') attribute_value.user_id ||= current_user.id attribute_value.save! @@ -614,15 +823,21 @@ def name_field_update def text_field_update text = field_params.fetch('value', '') @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) - attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) + attribute_value = @attribute_field.attribute_values.find_or_initialize_by( + entity_params.merge(attribute_field_id: @attribute_field.id) + ) attribute_value.user_id ||= current_user.id attribute_value.value = text attribute_value.save! - UpdateTextAttributeReferencesJob.perform_later(attribute_value.id) + begin + UpdateTextAttributeReferencesJob.perform_later(attribute_value.id) + rescue RedisClient::CannotConnectError, Redis::CannotConnectError => e + Rails.logger.error "[Mentions] Redis unavailable - mention jobs not enqueued for Attribute##{attribute_value.id}: #{e.message}" + end respond_to do |format| - format.html { redirect_back(fallback_location: root_path, notice: "#{@attribute_field.label} updated!") } + format.html { redirect_back(fallback_location: root_path, notice: "#{attribute_value.entity.name}'s #{@attribute_field.label.downcase} updated!") } format.json { render json: attribute_value.to_json, status: 200 } end end @@ -631,7 +846,9 @@ def tags_field_update return unless valid_content_types.include?(entity_params.fetch('entity_type')) @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) - attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) + attribute_value = @attribute_field.attribute_values.find_or_initialize_by( + entity_params.merge(attribute_field_id: @attribute_field.id) + ) attribute_value.user_id ||= current_user.id attribute_value.value = field_params.fetch('value', '') attribute_value.save! @@ -641,14 +858,19 @@ def tags_field_update @content = @entity update_page_tags - render json: attribute_value.to_json, status: 200 + respond_to do |format| + format.html { redirect_back(fallback_location: root_path, notice: "#{attribute_value.entity.name}'s #{@attribute_field.label.downcase} updated!") } + format.json { render json: attribute_value.to_json, status: 200 } + end end def universe_field_update return unless valid_content_types.include?(entity_params.fetch('entity_type')) @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) - attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) + attribute_value = @attribute_field.attribute_values.find_or_initialize_by( + entity_params.merge(attribute_field_id: @attribute_field.id) + ) attribute_value.user_id ||= current_user.id new_universe_id = field_params.fetch('value', nil).to_i @@ -667,6 +889,20 @@ def universe_field_update private + def group_events_by_date(events) + # Group events by date for timeline display + grouped = events.group_by { |event| event.created_at.to_date } + + grouped.map do |date, date_events| + { + date: date, + events: date_events, + total_field_changes: date_events.sum { |event| event.changed_fields.keys.length }, + users: date_events.map(&:user).compact.uniq + } + end.sort_by { |group| group[:date] }.reverse + end + def update_page_tags tag_list = field_params.fetch('value', '').split(PageTag::SUBMISSION_DELIMITER) current_tags = @content.page_tags.pluck(:tag) @@ -711,12 +947,6 @@ def render_json(content) }) end - # todo just do the migration for everyone so we can finally get rid of this - def migrate_old_style_field_values - content ||= content_type_from_controller(self.class).find_by(id: params[:id]) - TemporaryFieldMigrationService.migrate_fields_for_content(content, current_user) if content.present? - end - def valid_content_types Rails.application.config.content_type_names[:all] end @@ -813,42 +1043,6 @@ def set_entity @entity = entity_page_type.constantize.find_by(id: entity_page_id) end - # For index, new, edit - # def set_general_navbar_actions - # content_type = @content_type_class || content_type_from_controller(self.class) - # return if [AttributeCategory, AttributeField, Attribute].include?(content_type) - - # @navbar_actions = [] - - # if @current_user_content - # @navbar_actions << { - # label: "Your #{view_context.pluralize @current_user_content.fetch(content_type.name, []).count, content_type.name.downcase}", - # href: main_app.polymorphic_path(content_type) - # } - # end - - # @navbar_actions << { - # label: "New #{content_type.name.downcase}", - # href: main_app.new_polymorphic_path(content_type), - # class: 'right' - # } if user_signed_in? && current_user.can_create?(content_type) \ - # || PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: content_type.name) - - # discussions_link = ForumsLinkbuilderService.worldbuilding_url(content_type) - # if discussions_link.present? - # @navbar_actions << { - # label: 'Discussions', - # href: discussions_link - # } - # end - - # # @navbar_actions << { - # # label: 'Customize template', - # # class: 'right', - # # href: main_app.attribute_customization_path(content_type.name.downcase) - # # } - # end - # For showing a specific piece of content def set_navbar_actions content_type = @content_type_class || content_type_from_controller(self.class) @@ -856,23 +1050,9 @@ def set_navbar_actions return if [AttributeCategory, AttributeField].include?(content_type) - # Set up navbar actions for gallery specifically - if action_name == 'gallery' && @content.present? - # Add a link to view the content page - @navbar_actions << { - label: @content.name, - href: polymorphic_path(@content) - } - - # Add a gallery title indicator - @navbar_actions << { - label: 'Gallery', - href: send("gallery_#{@content.class.name.downcase}_path", @content) - } - end end def set_sidenav_expansion @sidenav_expansion = 'worldbuilding' end -end +end \ No newline at end of file diff --git a/app/controllers/content_page_shares_controller.rb b/app/controllers/content_page_shares_controller.rb index 9a0737453..6f1498731 100644 --- a/app/controllers/content_page_shares_controller.rb +++ b/app/controllers/content_page_shares_controller.rb @@ -1,9 +1,11 @@ class ContentPageSharesController < ApplicationController before_action :authenticate_user!, except: [:show] before_action :set_content_page_share, only: [ - :show, :edit, :update, :destroy, + :show, :edit, :update, :destroy, :follow, :unfollow, :report ] + before_action :authorize_destroy!, only: [:destroy] + before_action :load_recent_forum_topics, only: [:show] # GET /content_page_shares def index @@ -15,6 +17,15 @@ def show @page_title = "#{@share.user.display_name}'s #{@share.content_page_type} shared" @sidenav_expansion = 'community' + + # Set up follow/block status for the share creator + if current_user + @is_following = current_user.user_followings.exists?(followed_user: @share.user) + @is_blocked = current_user.user_blockings.exists?(blocked_user: @share.user) + else + @is_following = false + @is_blocked = false + end end # GET /content_page_shares/new @@ -33,7 +44,23 @@ def create if @share.save @share.content_page.update(privacy: 'public') - redirect_to [@share.user, @share], notice: 'Content page share was successfully created.' + # Notify the content creator if they're different from the sharer + content_owner = @share.content_page.user + if content_owner != current_user && content_owner.notification_updates? + content_type_name = @share.content_page.class.name.downcase + content_type_color = @share.content_page.class.respond_to?(:color) ? @share.content_page.class.color : 'bg-blue-500' + + content_owner.notifications.create( + message_html: "🎉 #{current_user.display_name} shared your #{content_type_name} #{@share.content_page.name} with the community!", + icon: 'campaign', + icon_color: 'green', + happened_at: DateTime.current, + passthrough_link: user_content_page_share_path(@share.user, @share), + reference_code: 'content-shared-by-other' + ) + end + + redirect_to [@share.user, @share], notice: 'Thanks for sharing!' else raise @share.errors.inspect end @@ -76,6 +103,12 @@ def set_content_page_share @share = ContentPageShare.find(params[:id]) end + def authorize_destroy! + unless @share.user == current_user || current_user.site_administrator? + redirect_to [@share.user, @share], alert: 'You are not authorized to delete this share.' + end + end + # Only allow a trusted parameter "white list" through. def content_page_share_params { @@ -86,4 +119,23 @@ def content_page_share_params shared_at: DateTime.current } end + + def load_recent_forum_topics + # Get the 5 most recent forum posts and their topics + recent_posts = Thredded::Post.joins(:topic) + .where(deleted_at: nil) + .where.not(moderation_state: :blocked) + .where("thredded_topics.moderation_state != ?", 2) + .order(created_at: :desc) + .limit(10) + .includes(:topic, :user) + + # Get unique topics from recent posts, limited to 5 + @recent_forum_topics = recent_posts.map(&:topic) + .uniq { |topic| topic.id } + .first(5) + rescue => e + Rails.logger.error "Error loading recent forum topics: #{e.message}" + @recent_forum_topics = [] + end end \ No newline at end of file diff --git a/app/controllers/contributors_controller.rb b/app/controllers/contributors_controller.rb index 6d2207543..4fb9c1974 100644 --- a/app/controllers/contributors_controller.rb +++ b/app/controllers/contributors_controller.rb @@ -1,4 +1,31 @@ class ContributorsController < ApplicationController + before_action :authenticate_user! + + def create + universe = Universe.find(params[:universe_id]) + + # Check if the current user is the owner of the universe + unless universe.user_id == current_user.id + redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Only the universe owner can add contributors.' + return + end + + email = params[:contributor][:email]&.downcase + + # Check if this email is already a contributor + if universe.contributors.exists?(email: email) + redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'This user is already a contributor.' + return + end + + # Use the ContributorService to handle the invitation + ContributorService.invite_contributor_to_universe(universe: universe, email: email) + + redirect_to edit_universe_path(universe, anchor: 'contributors'), notice: 'Contributor invitation sent!' + rescue StandardError => e + redirect_to edit_universe_path(universe, anchor: 'contributors'), alert: 'Failed to add contributor. Please try again.' + end + def destroy contributor = Contributor.find(params[:id]) relevant_universe = Universe.find(contributor.universe_id) @@ -30,8 +57,18 @@ def destroy #todo send "you've been removed as a contributor" email end + # Set a flash message for feedback + if user.present? && user.id == current_user.id + # User removed themselves + flash[:notice] = "You have left the universe '#{relevant_universe.name}'" + else + # Owner removed a contributor + flash[:notice] = "Contributor removed successfully" + end + # A 303 status is required here so the browser doesn't redirect with a DELETE action # https://stackoverflow.com/questions/14598703/rails-redirect-after-delete-using-delete-instead-of-get - redirect_to universes_path, status: 303 + # Redirect back to the universe edit page with contributors anchor + redirect_to edit_universe_path(relevant_universe, anchor: 'contributors'), status: 303 end end diff --git a/app/controllers/customization_controller.rb b/app/controllers/customization_controller.rb index 5384de9d1..200c99a28 100644 --- a/app/controllers/customization_controller.rb +++ b/app/controllers/customization_controller.rb @@ -1,14 +1,12 @@ class CustomizationController < ApplicationController - # todo require login for all actions :O - + before_action :authenticate_user!, except: [:content_types] before_action :verify_content_type_can_be_toggled, only: [:toggle_content_type] def content_types - return redirect_to(root_path) unless user_signed_in? - @all_content_types = Rails.application.config.content_types[:all] @premium_content_types = Rails.application.config.content_types[:premium] - @my_activators = current_user.user_content_type_activators.pluck(:content_type) + @my_activators = user_signed_in? ? current_user.user_content_type_activators.pluck(:content_type) : [] + @sidenav_expansion = 'worldbuilding' @page_title = "Customize your notebook pages" end diff --git a/app/controllers/data_controller.rb b/app/controllers/data_controller.rb index be56236aa..73e54aa3d 100644 --- a/app/controllers/data_controller.rb +++ b/app/controllers/data_controller.rb @@ -18,18 +18,17 @@ def review_year @created_content = {} @total_created_non_universe_content = 0 - @words_written = 0 + + # Calculate words written during this year using delta calculation + year_range = comparable_year.beginning_of_year.to_date..comparable_year.end_of_year.to_date + @words_written = WordCountUpdate.words_written_in_range(current_user, year_range) + Rails.application.config.content_types[:all].each do |klass| @created_content[klass.name] = klass.where(user_id: current_user.id) .where('created_at > ?', comparable_year.beginning_of_year) .where('created_at < ?', comparable_year.end_of_year) .order('created_at ASC') - @words_written += WordCountUpdate.where( - entity_type: klass.name, - entity_id: @created_content[klass.name].map(&:id) - ).sum(:word_count) - if klass.name != 'Universe' @total_created_non_universe_content += @created_content[klass.name].count end @@ -40,8 +39,6 @@ def review_year .where('created_at < ?', comparable_year.end_of_year) .order('created_at ASC') - @words_written += @created_content['Document'].sum(:cached_word_count) - earliest_page_date = DateTime.current @earliest_page = nil @created_content.each do |content_type, list| @@ -108,7 +105,14 @@ def documents end def uploads - @used_kb = current_user.image_uploads.sum(:src_file_size) / 1000 + # Calculate total size by iterating through uploads instead of using SQL sum + # This avoids the "no such column: src_file_size" error + total_size = 0 + current_user.image_uploads.each do |upload| + total_size += upload.src_file_size if upload.src_file_size.present? + end + + @used_kb = total_size / 1000 @remaining_kb = current_user.upload_bandwidth_kb.abs if current_user.upload_bandwidth_kb < 0 @@ -116,12 +120,19 @@ def uploads else @percent_used = (@used_kb.to_f / (@used_kb + @remaining_kb) * 100).round(3) end + + # Preload content associations for better performance + @uploads = current_user.image_uploads.includes(:content).order(created_at: :desc) end def usage @content = current_user.content end + def achievements + @content = current_user.content + end + def tags @tags = current_user.page_tags end @@ -152,10 +163,62 @@ def referrals end def green + # Timeline events - query ONCE + timeline_ids = @current_user_content['Timeline']&.map(&:id) || [] + @timeline_event_count = timeline_ids.any? ? TimelineEvent.where(timeline_id: timeline_ids).count : 0 + + # Personal content stats (calculated once, reused throughout view) + @personal_stats = calculate_personal_green_stats + + # Community stats - cache expensive calculation + @community_stats = calculate_community_green_stats end private + def calculate_personal_green_stats + stats = { pages_equivalent: 0, by_type: {} } + + @current_user_content.reject { |type, _| type == 'Book' }.each do |content_type, content_list| + count = content_list.count + next if count == 0 + + pages = case content_type + when 'Timeline' + GreenService::AVERAGE_TIMELINE_EVENTS_PER_PAGE * @timeline_event_count + when 'Document' + word_sum = content_list.sum { |d| d.cached_word_count || 0 } + [word_sum / GreenService::AVERAGE_WORDS_PER_PAGE.to_f, count].max + else + GreenService.physical_pages_equivalent_for(content_type) * count + end + + stats[:by_type][content_type] = { count: count, pages: pages.round } + stats[:pages_equivalent] += pages + end + + stats[:trees_saved] = stats[:pages_equivalent] / GreenService::SHEETS_OF_PAPER_PER_TREE.to_f + stats + end + + def calculate_community_green_stats + Rails.cache.fetch('green_community_stats', expires_in: 1.hour) do + pages = 0 + (Rails.application.config.content_type_names[:all] - ['Book'] + ['Timeline', 'Document']).each do |type| + pages += case type + when 'Timeline' + GreenService.total_timeline_pages_equivalent + when 'Document' + GreenService.total_document_pages_equivalent + else + klass = type.constantize + GreenService.physical_pages_equivalent_for(type) * klass.unscoped.count + end + end + { pages_equivalent: pages, trees_saved: pages / GreenService::SHEETS_OF_PAPER_PER_TREE.to_f } + end + end + def set_sidenav_expansion @sidenav_expansion = 'my account' end diff --git a/app/controllers/document_analyses_controller.rb b/app/controllers/document_analyses_controller.rb index 08b974e91..32e116b46 100644 --- a/app/controllers/document_analyses_controller.rb +++ b/app/controllers/document_analyses_controller.rb @@ -1,16 +1,51 @@ class DocumentAnalysesController < ApplicationController - before_action :authenticate_user! - before_action :set_document - before_action :authorize_user_for_document - before_action :set_document_analysis + before_action :authenticate_user!, except: [:index, :landing] + before_action :set_document, except: [:index, :landing, :hub] + before_action :authorize_user_for_document, except: [:index, :landing, :hub] + before_action :set_document_analysis, except: [:index, :landing, :hub] before_action :set_navbar_color before_action :set_sidenav_expansion - before_action :set_navbar_actions + # before_action :set_navbar_actions + + # Document analysis landing page for logged out users + def landing + redirect_to analysis_hub_path if user_signed_in? + + # Set SEO metadata + set_meta_tags title: "Document Analysis - Notebook.ai", + description: "Analyze your writing for readability, style, sentiment, and more with Notebook.ai's AI-powered document analysis tools.", + keywords: "document analysis, writing analysis, readability, style analysis, sentiment analysis, AI writing tools" + end - # def index - # @document_analyses = DocumentAnalysis.all - # end + # Document analysis hub for logged in users + def hub + redirect_to landing_path unless user_signed_in? + + # Get the user's recent documents + @recent_documents = current_user.documents.order(updated_at: :desc).limit(5) if user_signed_in? + + # Get the user's recent analyses + @recent_analyses = DocumentAnalysis.joins(:document) + .where(documents: { user_id: current_user.id }) + .where.not(completed_at: nil) + .order(completed_at: :desc) + .limit(5) if user_signed_in? + + # Get overall analysis stats + @total_analyses_count = DocumentAnalysis.joins(:document) + .where(documents: { user_id: current_user.id }) + .where.not(completed_at: nil) + .count if user_signed_in? + end + + def index + if user_signed_in? + @document_analyses = DocumentAnalysis.joins(:document).where(documents: { user_id: current_user.id }) + else + @document_analyses = DocumentAnalysis.joins(:document).where(documents: { privacy: 'public' }) + end + end def show @navbar_actions = [] unless @analysis.present? @@ -26,13 +61,18 @@ def style # end def sentiment - @document_sentiment_color = (@analysis.sentiment_score < 0) ? 'blue' : 'green' + unless @analysis&.has_sentiment_scores? + redirect_to analysis_document_path(@document), notice: "Sentiment data is not yet available for this document." + return + end + + @document_sentiment_color = (@analysis.sentiment_score.to_f < 0) ? 'blue' : 'green' @document_emotion_data = Hash[{ - "Anger" => (100 * @analysis.anger_score).round(1), - "Fear" => (100 * @analysis.fear_score).round(1), - "Sadness" => (100 * @analysis.sadness_score).round(1), - "Disgust" => (100 * @analysis.disgust_score).round(1), - "Joy" => (100 * @analysis.joy_score).round(1) + "Anger" => (100 * (@analysis.anger_score || 0)).round(1), + "Fear" => (100 * (@analysis.fear_score || 0)).round(1), + "Sadness" => (100 * (@analysis.sadness_score || 0)).round(1), + "Disgust" => (100 * (@analysis.disgust_score || 0)).round(1), + "Joy" => (100 * (@analysis.joy_score || 0)).round(1) }.sort_by(&:second).reverse] @document_dominant_emotion = @document_emotion_data.keys.first @document_secondary_emotion = @document_emotion_data.keys.second diff --git a/app/controllers/document_revisions_controller.rb b/app/controllers/document_revisions_controller.rb index 7097c524d..9d7d968c4 100644 --- a/app/controllers/document_revisions_controller.rb +++ b/app/controllers/document_revisions_controller.rb @@ -1,16 +1,280 @@ class DocumentRevisionsController < ApplicationController - before_action :set_document, only: [:index, :show, :destroy] - before_action :set_document_revision, only: [:show, :edit, :update, :destroy] + before_action :set_document, only: [:index, :show, :destroy, :diff, :restore] + before_action :set_document_revision, only: [:show, :edit, :update, :destroy, :diff, :restore] # GET /document_revisions def index - @document_revisions = @document.document_revisions.order('created_at DESC').paginate(page: params[:page], per_page: 10) + @document_revisions = @document.document_revisions.order('created_at DESC').paginate(page: params[:page], per_page: 60) end # GET /document_revisions/1 def show end + # GET /document_revisions/1/diff + def diff + require 'diffy' + require 'nokogiri' + + # Find the previous revision for comparison + previous_revision = @document.document_revisions + .where("created_at < ?", @document_revision.created_at) + .order(created_at: :desc) + .first + + if previous_revision + # Strip HTML and get clean text + previous_html = previous_revision.body.to_s + current_html = @document_revision.body.to_s + + previous_text = strip_and_clean_html(previous_html) + current_text = strip_and_clean_html(current_html) + + # For very large documents, truncate to avoid memory issues + max_length = 50_000 # ~10k words + if previous_text.length > max_length || current_text.length > max_length + diff_html = generate_author_friendly_diff(previous_text[0..max_length], current_text[0..max_length], truncated: true) + else + diff_html = generate_author_friendly_diff(previous_text, current_text) + end + + render html: diff_html.html_safe + else + # This is the first revision, show a friendly message + diff_html = generate_first_revision_view(strip_and_clean_html(@document_revision.body.to_s)) + render html: diff_html.html_safe + end + end + + + # POST /document_revisions/1/restore + def restore + # First, create a backup revision of the current document state + current_revision = @document.document_revisions.create!( + title: @document.title, + body: @document.body, + synopsis: @document.synopsis, + universe_id: @document.universe_id, + notes_text: @document.notes_text, + cached_word_count: @document.cached_word_count || 0 + ) + + # Then restore the selected revision's content to the document + @document.update!( + title: @document_revision.title, + body: @document_revision.body, + synopsis: @document_revision.synopsis, + notes_text: @document_revision.notes_text, + cached_word_count: @document_revision.cached_word_count + ) + + redirect_to edit_document_path(@document), notice: "Document successfully restored to revision from #{@document_revision.created_at.strftime('%B %d, %Y at %I:%M %p')}. Your previous version has been saved as a new revision." + rescue => e + Rails.logger.error "Failed to restore revision #{@document_revision.id} for document #{@document.id}: #{e.message}" + redirect_to document_document_revisions_path(@document), alert: 'Failed to restore revision. Please try again.' + end + + def strip_and_clean_html(html_text) + # Convert HTML to clean text, preserving paragraph breaks + doc = Nokogiri::HTML(html_text) + + # Remove script and style elements + doc.css('script, style').remove + + # Convert block elements to preserve structure + # Add double newlines after paragraphs for clear separation + doc.css('p').each { |p| p.after("\n\n") } + doc.css('div').each { |div| div.after("\n") } + doc.css('br').each { |br| br.replace("\n") } + doc.css('h1, h2, h3, h4, h5, h6').each { |h| h.after("\n\n") } + doc.css('blockquote').each { |bq| bq.after("\n\n") } + + # Get text and clean up whitespace while preserving paragraph structure + text = doc.text + text.gsub(/\r\n?/, "\n") # Normalize line endings + .gsub(/[ \t]+/, ' ') # Collapse spaces and tabs (but NOT newlines) + .gsub(/ *\n */, "\n") # Remove spaces around newlines + .gsub(/\n{3,}/, "\n\n") # Reduce 3+ consecutive newlines to exactly 2 + .gsub(/\A\n+/, '') # Remove leading newlines + .gsub(/\n+\z/, '') # Remove trailing newlines + .strip + end + + def generate_author_friendly_diff(previous_text, current_text, truncated: false) + # Use diffy to get the diff directly on the text, preserving original formatting + # This maintains newlines and text structure as the author intended + diff = Diffy::Diff.new(previous_text, current_text, context: 3) + + # Calculate statistics + prev_words = previous_text.split.size + curr_words = current_text.split.size + word_diff = curr_words - prev_words + + # Build the HTML + html = <<~HTML +
    + +
    +

    Changes Summary

    +
    +
    +
    + + #{word_diff > 0 ? '+' : ''}#{word_diff} words + +
    +
    + Previous: #{prev_words} words → Current: #{curr_words} words +
    + #{truncated ? '
    warningDocument truncated for performance
    ' : ''} +
    +
    + + +
    + +
    +

    + history + Previous Version +

    +
    + #{format_version_column(diff, :old, previous_text, current_text)} +
    +
    + + +
    +

    + check_circle + Current Version +

    +
    + #{format_version_column(diff, :new, previous_text, current_text)} +
    +
    +
    + + +
    +
    +
    + + Removed text +
    +
    + + Added text +
    +
    + + Unchanged text +
    +
    +
    +
    + HTML + + html + end + + def format_version_column(diff, version, previous_text, current_text) + formatted_html = "" + + diff.to_s(:text).each_line do |line| + next if line.start_with?('@') # Skip line numbers + next if line.strip == '\\ No newline at end of file' + + # Safely extract content, handling edge cases + if line.length > 1 + content = CGI.escapeHTML(line[1..-1].chomp) # Remove diff marker and newline + # Convert newlines to HTML line breaks for proper display + content = content.gsub(/\n/, '
    ') + else + content = "" + end + + # Get the diff marker (first character) + marker = line[0] || ' ' + + if version == :old + # Show removed and unchanged content for old version + case marker + when '-' + # Removed content - show in red with strikethrough + formatted_html += "

    #{content}

    " + when '+' + # Added content - skip in old version + when ' ' + # Context/unchanged content - show normally + formatted_html += "

    #{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 +
    +
    + celebration +

    First Revision Created!

    +

    This is the initial version of your document.

    + +
    +
    + Document Preview + #{word_count} words +
    +
    +

    #{CGI.escapeHTML(preview)}

    +
    +
    + +

    + Future changes to your document will be tracked and displayed here. +

    +
    +
    + HTML + end + # GET /document_revisions/new def new @document_revision = DocumentRevision.new diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index af5a9b625..8a0d5c042 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -1,10 +1,12 @@ class DocumentsController < ApplicationController + layout :determine_layout + before_action :authenticate_user!, except: [:show, :analysis] # todo Uh, this is a hack. The CSRF token on document editor model to add entities is being rejected... for whatever reason. skip_before_action :verify_authenticity_token, only: [:link_entity] - before_action :set_document, only: [:show, :analysis, :plaintext, :queue_analysis, :edit, :destroy] + before_action :set_document, only: [:show, :analysis, :plaintext, :printable, :queue_analysis, :edit, :destroy] before_action :set_sidenav_expansion, except: [:plaintext] before_action :set_navbar_color, except: [:plaintext] before_action :set_navbar_actions, except: [:edit, :plaintext] @@ -18,23 +20,64 @@ class DocumentsController < ApplicationController before_action :cache_linkable_content_for_each_content_type, only: [:edit] - layout 'editor', only: [:edit] - def index @page_title = "My documents" + @recent_documents = current_user .linkable_documents.order('updated_at DESC') .includes([:user, :page_tags, :universe]) + .where(folder_id: nil) # Only show documents not in folders + .limit(10) # Limit for sidebar display + # Apply sorting based on params @documents = current_user .linkable_documents - .order('favorite DESC, title ASC, updated_at DESC') .includes([:user, :page_tags, :universe]) + .where(folder_id: nil) # Only show documents not in folders at root level + + case params[:sort] + when 'alphabetical' + @documents = @documents.order(favorite: :desc, title: :asc) + when 'word_count' + @documents = @documents.order(favorite: :desc).order(Arel.sql('cached_word_count DESC NULLS LAST')) + when 'created' + @documents = @documents.order(favorite: :desc, created_at: :desc) + else # default to 'updated' or no param + @documents = @documents.order(favorite: :desc, updated_at: :desc) + end @folders = current_user .folders .where(context: 'Document', parent_folder_id: nil) .order('title ASC') + + # Apply global search if query param is present + if params[:q].present? + search_query = "%#{params[:q]}%" + @documents = @documents.where("title ILIKE ? OR body ILIKE ?", search_query, search_query) + @folders = @folders.where("title ILIKE ?", search_query) + end + + # Calculate frequent folders (top 5 by document count) + @frequent_folders = current_user + .folders + .where(context: 'Document') + .joins(:documents) + .group('folders.id') + .order('COUNT(documents.id) DESC') + .limit(5) + + # Note: Statistics are calculated directly in the view using @documents and @folders + # which are already filtered to show only root-level items (folder_id: nil) + + # Calculate writing streak using WordCountUpdate + calculate_writing_streak_data + + # Recent activity for feed + @recent_activity = current_user.linkable_documents + .order('updated_at DESC') + .limit(10) + .select(:id, :title, :updated_at, :cached_word_count, :user_id) # TODO: can we reuse this content to skip a few queries in this controller action? cache_linkable_content_for_each_content_type @@ -44,9 +87,13 @@ def index @documents = @documents.where(favorite: true) end + # Handle universe filtering from either @universe_scope or params[:universe_id] if @universe_scope @documents = @documents.where(universe: @universe_scope) @recent_documents = @recent_documents.where(universe: @universe_scope) + elsif params[:universe_id].present? + @documents = @documents.where(universe_id: params[:universe_id]) + @recent_documents = @recent_documents.where(universe_id: params[:universe_id]) end @recent_documents = @recent_documents.limit(6) @@ -56,12 +103,18 @@ def index page_id: @documents.map(&:id) ).order(:tag) + @filtered_page_tags = [] if params.key?(:tag) @filtered_page_tags = @page_tags.where(slug: params[:tag]) @documents = @documents.to_a.select { |document| @filtered_page_tags.pluck(:page_id).include?(document.id) } # @documents = @documents.where(page_tags: { slug: params[:tag] }) end + # Filter by status (supports multiple statuses via statuses[] param) + if params[:statuses].present? + @documents = @documents.where(status: params[:statuses]) + end + @page_tags = @page_tags.uniq(&:tag) @suggested_page_tags = (@page_tags.pluck(:tag) + PageTagService.suggested_tags_for('Document')).uniq end @@ -109,7 +162,7 @@ def queue_analysis # todo this function is an embarassment def link_entity # Preconditions lol - unless (Rails.application.config.content_types[:all].map(&:name) + [Timeline.name, Document.name]).include?(linked_entity_params[:entity_type]) + unless (Rails.application.config.content_types[:all].map(&:name) + [Timeline.name, Document.name, Book.name]).include?(linked_entity_params[:entity_type]) raise "Invalid entity type #{linked_entity_params[:entity_type]}" end @@ -138,16 +191,74 @@ def link_entity # Now that we have the analysis reference, we just create a new DocumentEntity on it for the associated page page = linked_entity_params[:entity_type].constantize.find(linked_entity_params[:entity_id]) # raises exception if not found :+1: + # Check if already linked (prevent duplicates) + existing_entity = document_analysis.document_entities.find_by( + entity_type: linked_entity_params[:entity_type], + entity_id: linked_entity_params[:entity_id] + ) + + if existing_entity.present? + # Already linked - return success but indicate it was already linked + respond_to do |format| + format.html { redirect_back(fallback_location: analysis_document_path(document_analysis.document), notice: "Page is already linked!") } + format.json do + render json: { success: true, already_linked: true, message: "#{page.name} is already linked to this document." } + end + end + return + end + document_entity = document_analysis.document_entities.create!( entity_type: linked_entity_params[:entity_type], entity_id: linked_entity_params[:entity_id], text: page.name ) - # # Finally, we need to kick off another analysis job to fetch information about this entity + # Finally, we need to kick off another analysis job to fetch information about this entity document_entity.analyze! if current_user.on_premium_plan? - return redirect_back(fallback_location: analysis_document_path(document_entity.document_analysis.document), notice: "Page linked!") + # Return JSON for AJAX requests, redirect for regular requests + respond_to do |format| + format.html { redirect_back(fallback_location: analysis_document_path(document_entity.document_analysis.document), notice: "Page linked!") } + format.json do + # Load the entity with its attributes for display + entity = document_entity.entity + entity_class = entity.class + + # Render the card partial as HTML for instant UI insertion + card_html = render_to_string( + partial: 'documents/linked_entity_card', + locals: { document_entity: document_entity }, + formats: [:html] + ) + + render json: { + success: true, + card_html: card_html, + document_entity: { + id: document_entity.id, + entity_type: document_entity.entity_type, + entity_id: document_entity.entity_id, + entity: { + id: entity.id, + name: entity.name, + description: entity.try(:description), + role: entity.try(:role), + type_of: entity.try(:type_of), + item_type: entity.try(:item_type), + summary: entity.try(:summary), + class_color: entity_class.color, + class_text_color: entity_class.text_color, + class_icon: entity_class.icon, + class_name: entity_class.name, + view_path: polymorphic_path(entity_class.name.downcase, id: entity.id) + } + } + } + end + end + + return else # If we pass in an actual ID for the document entity, we're modifying an existing one @@ -160,7 +271,38 @@ def link_entity entity_id: linked_entity_params[:entity_id].to_i ) - return redirect_to(analysis_document_path(document_entity.document_analysis.document), notice: "Page linked!") + respond_to do |format| + format.html { redirect_to(analysis_document_path(document_entity.document_analysis.document), notice: "Page linked!") } + format.json do + entity = document_entity.entity + entity_class = entity.class + + render json: { + success: true, + document_entity: { + id: document_entity.id, + entity_type: document_entity.entity_type, + entity_id: document_entity.entity_id, + entity: { + id: entity.id, + name: entity.name, + description: entity.try(:description), + role: entity.try(:role), + type_of: entity.try(:type_of), + item_type: entity.try(:item_type), + summary: entity.try(:summary), + class_color: entity_class.color, + class_text_color: entity_class.text_color, + class_icon: entity_class.icon, + class_name: entity_class.name, + view_path: polymorphic_path(entity_class.name.downcase, id: entity.id) + } + } + } + end + end + + return end end end @@ -176,9 +318,44 @@ def new def edit redirect_to(root_path, notice: "You don't have permission to edit that!") unless @document.updatable_by?(current_user) + # Fetch all document entities (linked and unlinked) + # Note: We can't use includes(:entity) effectively on polymorphic associations + # because Rails doesn't know which tables to join. Instead, we'll load entities + # in bulk by type below. @linked_entities = @document.document_entities .where.not(entity_id: nil) - .group_by(&:entity_type) + .to_a + + # Eager-load polymorphic entities by grouping by type and batch-loading + # This prevents N+1 queries when rendering linked_entity_card partials + entities_by_type = @linked_entities.group_by(&:entity_type) + preloaded_entities = {} + + entities_by_type.each do |entity_type, document_entities| + entity_ids = document_entities.map(&:entity_id) + entity_class = entity_type.safe_constantize + next unless entity_class + + # Load all entities of this type in a single query + entities = entity_class.where(id: entity_ids).index_by(&:id) + preloaded_entities[entity_type] = entities + end + + # Associate preloaded entities back to document_entities to avoid N+1 + @linked_entities.each do |document_entity| + entity = preloaded_entities.dig(document_entity.entity_type, document_entity.entity_id) + document_entity.association(:entity).target = entity if entity + end + + # Group by entity type for easier rendering + @linked_entities_by_type = @linked_entities.group_by(&:entity_type) + + # Books sidebar data - eager load documents through book_documents + @document_books = @document.books + .includes(book_documents: :document) + .order(:name) + .to_a + @user_books = current_user.books.unarchived.order(:name) end def create @@ -200,12 +377,20 @@ def update d_params[:universe_id] = nil end - # Only queue document mentions for analysis if the document body has changed - DocumentMentionJob.perform_later(document.id) if d_params.key?(:body) - - update_page_tags(document) if document_tag_params - + # Save the document first - this is critical and must succeed if document.update(d_params) + # Update tags after successful save + update_page_tags(document) if document_tag_params + + # Queue background jobs only after successful save, and fail gracefully if Redis is down + begin + # Only queue document mentions for analysis if the document body has changed + DocumentMentionJob.perform_later(document.id) if d_params.key?(:body) + rescue RedisClient::CannotConnectError, Redis::CannotConnectError => e + # Log the error but don't fail the save - the document is already saved + Rails.logger.warn "Could not queue DocumentMentionJob due to Redis connection error: #{e.message}" + end + head 200, content_type: "text/html" else head 501, content_type: "text/html" @@ -217,18 +402,62 @@ def plaintext return redirect_to(root_path, notice: "That document either doesn't exist or you don't have permission to view it.", status: :not_found) end - render layout: 'plaintext' + render + end + + def printable + unless @document.present? && (current_user || User.new).can_read?(@document) + return redirect_to(root_path, notice: "That document either doesn't exist or you don't have permission to view it.", status: :not_found) + end + + render layout: 'printable' end def toggle_favorite document = Document.with_deleted.find_or_initialize_by(id: params[:id]) unless document.updatable_by?(current_user) - flash[:notice] = "You don't have permission to edit that!" - return redirect_back fallback_location: document + render json: { error: "You don't have permission to edit that!" }, status: :forbidden + return end - document.update!(favorite: !document.favorite) + if document.update(favorite: !document.favorite) + render json: { success: true, favorite: document.favorite } + else + render json: { error: "Failed to update favorite status" }, status: :unprocessable_entity + end + end + + def toggle_archive + document = Document.find_by(id: params[:id]) + return head :not_found unless document.present? + + unless document.updatable_by?(current_user) + respond_to do |format| + format.html { redirect_to(root_path, notice: "You don't have permission to do that!") } + format.json { render json: { error: "You don't have permission to do that!" }, status: :forbidden } + end + return + end + + verb = document.archived? ? "unarchived" : "archived" + success = document.archived? ? document.unarchive! : document.archive! + + respond_to do |format| + if success + format.html do + if verb == "archived" + redirect_to archive_path, notice: "Document archived." + else + redirect_to document, notice: "Document unarchived." + end + end + format.json { render json: { success: true, archived: document.archived?, verb: verb } } + else + format.html { redirect_back(fallback_location: document, notice: "Failed to #{verb.chomp('d')} document.") } + format.json { render json: { error: "Failed to #{verb.chomp('d')} document" }, status: :unprocessable_entity } + end + end end def destroy @@ -266,6 +495,23 @@ def unlink_entity redirect_back(fallback_location: document, notice: "Page unlinked.") end + def unlink_entity_by_id + document = Document.find_by(id: params[:id]) + return head :not_found unless document.present? + return head :unauthorized unless user_signed_in? && document.user == current_user + + entity = document.document_entities.find_by(id: params[:entity_id]) + return head :not_found unless entity.present? + + entity_name = entity.entity&.name || 'Page' + entity.destroy + + respond_to do |format| + format.html { redirect_back(fallback_location: document, notice: "#{entity_name} unlinked.") } + format.json { render json: { success: true, message: "#{entity_name} unlinked from document." } } + end + end + def destroy_analysis # todo move this to analysis controller document = Document.find_by(id: params[:id]) @@ -293,8 +539,33 @@ def set_footer_visibility @show_footer = false end + # Determines which layout to use based on the current action + def determine_layout + if action_name == 'edit' + 'editor' + elsif action_name == 'plaintext' + 'plaintext' + else + 'application' # Default layout for other actions + end + end + private + def calculate_writing_streak_data + # Use user's timezone for date calculations + user_today = current_user.current_date_in_time_zone + + # Today's word count (actual delta from yesterday) + @words_written_today = WordCountUpdate.words_written_on_date(current_user, user_today) + + # This week's word count (actual delta from end of last week) + @words_written_this_week = WordCountUpdate.words_written_in_range( + current_user, + user_today.beginning_of_week..user_today + ) + end + def update_page_tags(document) tag_list = document_tag_params.fetch('value', '').split(PageTag::SUBMISSION_DELIMITER) current_tags = document.page_tags.pluck(:tag) @@ -318,7 +589,7 @@ def update_page_tags(document) end def document_params - params.require(:document).permit(:title, :body, :deleted_at, :privacy, :universe_id, :folder_id, :notes_text, :synopsis) + params.require(:document).permit(:title, :body, :deleted_at, :privacy, :universe_id, :folder_id, :notes_text, :synopsis, :status, :cached_word_count) end def document_tag_params diff --git a/app/controllers/folders_controller.rb b/app/controllers/folders_controller.rb index 77d44ee56..6f0fdb7ce 100644 --- a/app/controllers/folders_controller.rb +++ b/app/controllers/folders_controller.rb @@ -16,61 +16,144 @@ def update end def destroy - # Relocate all documents in this folder to the root "folder" - # TODO - I think we can handle this at the model association level with dependent: nullify, but I've never used it - Document.with_deleted.where(folder_id: @folder.id).update_all(folder_id: nil) - - # Relocate all child folders in this folder to the root "folder" - Folder.where(parent_folder_id: @folder.id).update_all(parent_folder_id: nil) - + # Store parent folder reference before deletion for redirect logic + parent_folder_id = @folder.parent_folder_id + redirect_destination = if parent_folder_id + folder_path(parent_folder_id) + else + documents_path + end + + # Relocate all documents to parent folder (or root if no parent) + Document.with_deleted.where(folder_id: @folder.id).update_all(folder_id: parent_folder_id) + + # Relocate all child folders to parent folder (or root if no parent) + Folder.where(parent_folder_id: @folder.id).update_all(parent_folder_id: parent_folder_id) + + folder_name = @folder.title @folder.destroy! - redirect_to(documents_path, notice: "Folder #{@folder.title} deleted!") + + notice_message = if parent_folder_id + "Folder #{folder_name} deleted! All content moved to parent folder." + else + "Folder #{folder_name} deleted! All content moved to root." + end + + redirect_to(redirect_destination, notice: notice_message) end def show @page_title = @folder.title || 'Untitled folder' - @parent_folder = @folder.parent_folder - @child_folders = Folder.where(parent_folder: @folder) + # Set up variables to match documents#index + @recent_documents = current_user + .linkable_documents.order('updated_at DESC') + .includes([:user, :page_tags, :universe]) + .limit(10) + + # Get documents in this folder + @documents = current_user + .linkable_documents + .includes([:user, :page_tags, :universe]) + .where(folder_id: @folder.id) + + # Apply sorting (same as documents#index) + case params[:sort] + when 'alphabetical' + @documents = @documents.order(favorite: :desc, title: :asc) + when 'word_count' + @documents = @documents.order(favorite: :desc).order(Arel.sql('cached_word_count DESC NULLS LAST')) + when 'created' + @documents = @documents.order(favorite: :desc, created_at: :desc) + else # default to 'updated' or no param + @documents = @documents.order(favorite: :desc, updated_at: :desc) + end + + # Get subfolders + @folders = current_user + .folders + .where(context: 'Document', parent_folder_id: @folder.id) .order('title ASC') - # TODO: probably want to cache this in @current_user_content if we need it anywhere else - @all_folders = current_user.folders + # Apply global search if query param is present + if params[:q].present? + search_query = "%#{params[:q]}%" + @documents = @documents.where("title ILIKE ? OR body ILIKE ?", search_query, search_query) + @folders = @folders.where("title ILIKE ?", search_query) + end + + # Calculate frequent folders (top 5 by document count) + @frequent_folders = current_user + .folders .where(context: 'Document') - .order('title ASC') + .joins(:documents) + .group('folders.id') + .order('COUNT(documents.id) DESC') + .limit(5) + + # Calculate writing streak using WordCountUpdate (same as documents#index) + calculate_writing_streak_data + + # Recent activity for feed + @recent_activity = current_user.linkable_documents + .order('updated_at DESC') + .limit(10) + .select(:id, :title, :updated_at, :cached_word_count, :user_id) - # TODO: can we reuse this content to skip a few queries in this controller action? cache_linkable_content_for_each_content_type - # TODO: add other content types here too - @content = Document - .where(folder: @folder) - .includes([:user, :page_tags, :universe]) - .order('documents.favorite DESC, documents.title ASC, documents.updated_at DESC') + # Filter by favorites if requested + if params.key?(:favorite_only) + @documents = @documents.where(favorite: true) + end + # Handle universe filtering if @universe_scope - @content = @content.where(universe: @universe_scope) + @documents = @documents.where(universe: @universe_scope) + @recent_documents = @recent_documents.where(universe: @universe_scope) + elsif params[:universe_id].present? + @documents = @documents.where(universe_id: params[:universe_id]) + @recent_documents = @recent_documents.where(universe_id: params[:universe_id]) end - if params.key?(:favorite_only) - @content = @content.where(favorite: true) - end + @recent_documents = @recent_documents.limit(6) + # Handle page tags @page_tags = PageTag.where( page_type: Document.name, - page_id: @content.pluck(:id) + page_id: @documents.map(&:id) ).order(:tag) + @filtered_page_tags = [] if params.key?(:tag) @filtered_page_tags = @page_tags.where(slug: params[:tag]) - - @content = @content.to_a.select { |content| @filtered_page_tags.pluck(:page_id).include?(content.id) } - # TODO: the above could probably be replaced with something like the below, but not sure on nesting syntax - # @content = @content.where(page_tags: { slug: @filtered_page_tags.pluck(:slug) }) + @documents = @documents.to_a.select { |document| @filtered_page_tags.pluck(:page_id).include?(document.id) } end @page_tags = @page_tags.uniq(&:tag) - @suggested_page_tags = (@page_tags.pluck(:slug) + PageTagService.suggested_tags_for('Document')).uniq + @suggested_page_tags = (@page_tags.pluck(:tag) + PageTagService.suggested_tags_for('Document')).uniq + + # Store the current folder for use in the view + @current_folder = @folder + + # Render the documents index view + render 'documents/index' + end + + private + + def calculate_writing_streak_data + # Use user's timezone for date calculations + user_today = current_user.current_date_in_time_zone + + # Today's word count (actual delta from yesterday) + @words_written_today = WordCountUpdate.words_written_on_date(current_user, user_today) + + # This week's word count (actual delta from end of last week) + @words_written_this_week = WordCountUpdate.words_written_in_range( + current_user, + user_today.beginning_of_week..user_today + ) end private diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 65ac6294d..3d0127a48 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -1,12 +1,69 @@ class HelpController < ApplicationController - before_action :authenticate_user! + # Make all help pages public for SEO and accessibility + # before_action :authenticate_user! before_action :set_sidenav_expansion def index - @page_title = "Help center" + @page_title = "Help Center" + @meta_description = "Get help with Notebook.ai - guides for worldbuilding, writing tools, page templates, and more. Find answers, tutorials, and contact support." end - + + def page_templates + @page_title = "Page Templates" + @meta_description = "Learn how to customize page templates in Notebook.ai to structure your creative content perfectly. Complete guide to categories, fields, and template management." + end + + def organizing_with_universes + @page_title = "Organizing with Universes" + @meta_description = "Master the art of worldbuilding organization with universes in Notebook.ai. Learn about privacy, collaboration, focus mode, and best practices for managing complex fictional worlds." + end + + def premium_features + @page_title = "Premium Features Guide" + @meta_description = "Comprehensive guide to Notebook.ai Premium features including advanced content types, document analysis, unlimited storage, timelines, collections, and collaboration tools." + end + + def free_features + @page_title = "Free Features Guide" + @meta_description = "Complete overview of free features in Notebook.ai including core worldbuilding pages, document creation, universe organization, community features, and collaboration tools." + end + + def your_first_universe + @page_title = "Your First Universe - Getting Started Guide" + @meta_description = "Step-by-step guide to creating your first fictional universe in Notebook.ai. Learn how to organize characters, locations, items, and build the foundation of your worldbuilding project." + end + + def page_visualization + @page_title = "Page Visualization with Basil" + @meta_description = "Complete guide to visualizing your worldbuilding content with Basil. Learn how to generate character portraits, location art, and item illustrations from your page details." + end + + def document_analysis + @page_title = "Document Analysis Guide" + @meta_description = "Master Notebook.ai's AI-powered document analysis feature. Learn how to automatically extract characters, locations, and plot elements from your manuscripts and stories." + end + + def organizing_with_tags + @page_title = "Organizing with Tags" + @meta_description = "Master the art of content organization using tags in Notebook.ai. Learn how to create, manage, and use tags to efficiently organize and discover your worldbuilding content." + end + + def your_account + @page_title = "Managing Your Account" + @meta_description = "Learn how to manage your Notebook.ai account settings, update your profile, change your password, customize notifications, and control your privacy preferences." + end + + def your_data + @page_title = "Your Data: Export, Backup & Recovery" + @meta_description = "Complete guide to managing your data in Notebook.ai. Learn about the Data Vault, export formats, recovering deleted content, archives, and storage limits." + end + + def troubleshooting + @page_title = "Troubleshooting & FAQ" + @meta_description = "Find answers to common questions and solutions to frequent issues in Notebook.ai. Troubleshooting guides for universes, content, collaboration, and more." + end + def set_sidenav_expansion @sidenav_expansion = 'my account' end diff --git a/app/controllers/image_upload_controller.rb b/app/controllers/image_upload_controller.rb index 94e487df8..7679fa834 100644 --- a/app/controllers/image_upload_controller.rb +++ b/app/controllers/image_upload_controller.rb @@ -8,13 +8,20 @@ def delete begin image = ImageUpload.find(image_id) rescue - render nothing: true, status: 400 + respond_to do |format| + format.html { redirect_back fallback_location: root_path, alert: 'Image not found.' } + format.all { render json: { error: 'Image not found' }, status: 400 } + end return end #todo authorizer for ImageUploads - if current_user.nil? || current_user.id != image.user.id - render nothing: true, status: 401 + owner_id = image.user_id || image.content&.user_id + if current_user.nil? || current_user.id != owner_id + respond_to do |format| + format.html { redirect_back fallback_location: root_path, alert: 'Unauthorized.' } + format.all { render json: { error: 'Unauthorized' }, status: 401 } + end return end @@ -26,6 +33,38 @@ def delete # And credit that space back to their bandwidth current_user.update(upload_bandwidth_kb: current_user.upload_bandwidth_kb + reclaimed_space_kb) - render json: { success: result }, status: 200 + respond_to do |format| + format.html { redirect_back fallback_location: root_path, notice: 'Image successfully deleted.' } + format.all { render json: { success: result }, status: 200 } + end + end + + def update + image_id = params[:id] + + begin + image = ImageUpload.find(image_id) + rescue + render json: { error: 'Image not found' }, status: 404 + return + end + + #todo authorizer for ImageUploads + if current_user.nil? || current_user.id != image.user.id + render json: { error: 'Unauthorized' }, status: 401 + return + end + + if image.update(image_upload_params) + render json: { success: true, notes: image.notes }, status: 200 + else + render json: { error: 'Could not update image' }, status: 422 + end + end + + private + + def image_upload_params + params.require(:image_upload).permit(:notes) end end diff --git a/app/controllers/information_controller.rb b/app/controllers/information_controller.rb index decd69a02..d0ac23cfd 100644 --- a/app/controllers/information_controller.rb +++ b/app/controllers/information_controller.rb @@ -1,5 +1,4 @@ class InformationController < ApplicationController - Rails.application.config.content_types[:all].each do |content_type| define_method(content_type.name.downcase.pluralize) do @content_type = content_type @@ -7,4 +6,22 @@ class InformationController < ApplicationController render :content_type end end + + # Override for Timeline since it doesn't use the attributes system + def timelines + @content_type = Timeline + render :timeline + end + + # Override for Book since it's not a standard content type + def books + @content_type = Book + render :book + end + + # Override for Document since it's not a standard content type + def documents + @content_type = Document + render :document + end end diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb index e5248c55a..003bc3045 100644 --- a/app/controllers/main_controller.rb +++ b/app/controllers/main_controller.rb @@ -1,10 +1,11 @@ # Controller for top-level pages of the site that do not have # an associated model class MainController < ApplicationController - layout 'landing', only: [:index, :about_notebook, :for_writers, :for_roleplayers, :for_friends] + #layout 'landing', only: [:about_notebook, :for_writers, :for_roleplayers, :for_friends] before_action :authenticate_user!, only: [:dashboard, :prompts, :notes, :recent_content] before_action :cache_linkable_content_for_each_content_type, only: [:dashboard, :prompts] + before_action :set_page_meta_tags before_action do if !user_signed_in? && params[:referral] @@ -14,6 +15,10 @@ class MainController < ApplicationController def index redirect_to(:dashboard) if user_signed_in? + + @resource ||= User.new + @resource_name = :user + @devise_mapping ||= Devise.mappings[:user] end def about_notebook @@ -25,18 +30,79 @@ def comingsoon def dashboard @page_title = "My notebook" + # Trending = topics with most posts in the last 30 days messageboard_ids_to_exclude = [38, 26, 31, 32, 30, 33, 27] - most_recent_posts = Thredded::Post.where.not(messageboard_id: messageboard_ids_to_exclude) - .where(moderation_state: "approved") - .order('id DESC') - .limit(300) - .shuffle - .first(3) - @most_recent_threads = Thredded::Topic.where(id: most_recent_posts.pluck(:postable_id)) - .where(moderation_state: "approved") - .includes(:posts, :messageboard) + @most_recent_threads = Thredded::Topic + .joins(:posts) + .where.not(messageboard_id: messageboard_ids_to_exclude) + .where(moderation_state: "approved") + .where('thredded_posts.created_at > ?', 30.days.ago) + .group('thredded_topics.id') + .order('COUNT(thredded_posts.id) DESC') + .includes(:messageboard) + .limit(6) + + # Check if user has any content for null state detection + cache_current_user_content + @user_has_content = @current_user_content.values.flatten.any? set_questionable_content # for questions + generate_dashboard_analytics # for activity chart and streak + + @sidenav_expansion = 'worldbuilding' + + # Eager load image uploads for dashboard sections that render visual cards + if @most_edited_pages + pages_to_preload = @most_edited_pages.first(20).map { |page, _| page }.select { |p| p.respond_to?(:image_uploads) } + ActiveRecord::Associations::Preloader.new.preload(pages_to_preload, :image_uploads) if pages_to_preload.any? + end + + if @recently_edited_pages + pages_to_preload = @recently_edited_pages.first(7).select { |p| p.respond_to?(:image_uploads) } + ActiveRecord::Associations::Preloader.new.preload(pages_to_preload, :image_uploads) if pages_to_preload.any? + end + end + + def table_of_contents + @universe = Universe.find(params[:id]) + + unless (current_user || User.new).can_read?(@universe) + raise ActiveRecord::RecordNotFound + end + + @toc_user = @universe.user + @page_title = "#{@universe.name} — Table of Contents" + + can_view_private = user_signed_in? && (current_user == @universe.user || Contributor.exists?(user_id: current_user.id, universe_id: @universe.id)) + + # Fetch books that belong to this universe for the Book shelf + @books = @universe.books.where(deleted_at: nil, archived_at: nil).order(:name) + @books = @books.is_public unless can_view_private + + # Gather all non-deleted content in this universe + all_pages = [] + @page_type_counts = Hash.new(0) + + Rails.application.config.content_types[:all_non_universe].each do |content_type| + relation = content_type.name.downcase.pluralize.to_sym + pages = @universe.send(relation).where(deleted_at: nil, archived_at: nil) + pages = pages.is_public unless can_view_private + + pages.each do |page| + all_pages << page + @page_type_counts[content_type.name] += 1 + end + end + + all_pages.sort_by!(&:name) + @starred_pages = all_pages.select { |p| p.try(:favorite) } + @other_pages = all_pages.reject { |p| p.try(:favorite) } + @pages_by_type = @other_pages.group_by(&:page_type) + + # Statistics + @total_pages = all_pages.size + @total_words = all_pages.sum { |p| p.try(:cached_word_count).to_i } + @content_type_count = @page_type_counts.keys.size end def infostack @@ -54,12 +120,35 @@ def paper @per_page_savings = {} - (Rails.application.config.content_types[:all] + [Timeline, Document]).each do |content_type| - physical_page_equivalent = GreenService.total_physical_pages_equivalent(content_type) + content_types = Rails.application.config.content_types[:all] + [Timeline, Document] + + # Batch-fetch all max IDs upfront to avoid N+1 queries + max_ids = GreenService.max_ids_for_content_types(content_types) + + content_types.each do |content_type| + # Determine the max_id for this content type + max_id = case content_type.name + when 'Timeline' + max_ids['TimelineEvent'] + when 'Document' + nil # Document uses word count calculation, not max ID + else + max_ids[content_type.name] + end + + physical_page_equivalent = GreenService.total_physical_pages_equivalent(content_type, max_id: max_id) tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE + # Use the same max_id for digital count (avoiding duplicate query) + digital_count = case content_type.name + when 'Document' + max_ids['Document'] + else + max_id || 0 + end + @per_page_savings[content_type.name] = { - digital: content_type.last.try(:id) || 0, + digital: digital_count, pages: physical_page_equivalent, trees: tree_equivalent } @@ -84,6 +173,245 @@ def notes end def recent_content + @page_title = "Recent Activity" + + # Get base content with enhanced data + cache_current_user_content + all_content = @current_user_content.values.flatten + + # Count edits on the objects directly (if we ever use ContentChangeEvent for non-Attributes) + direct_edit_counts = ContentChangeEvent.where(user_id: current_user.id) + .where.not(content_type: 'Attribute') + .group(:content_type, :content_id) + .count + + # Count edits on their Attributes + attribute_edit_counts = ContentChangeEvent.where(user_id: current_user.id, content_type: 'Attribute') + .joins("INNER JOIN attributes ON attributes.id = content_change_events.content_id") + .group('attributes.entity_type', 'attributes.entity_id') + .count + + # Add enhanced data to each content item + @enhanced_content = all_content.map do |content_page| + key = [content_page.page_type, content_page.id] + edit_count = (direct_edit_counts[key] || 0) + (attribute_edit_counts[key] || 0) + + { + page: content_page, + edit_count: edit_count, + action: content_page.created_at == content_page.updated_at ? 'created' : 'updated', + days_since_created: (Date.current - content_page.created_at.to_date).to_i, + days_since_updated: (Date.current - content_page.updated_at.to_date).to_i, + word_count: content_page.try(:cached_word_count) || 0, + has_image: content_page.respond_to?(:custom_thumbnail_url) && content_page.custom_thumbnail_url.present? + } + end + + # Apply filters + apply_content_filters + + # Apply sorting + apply_content_sorting + + # Generate activity analytics + generate_activity_analytics + + # Pagination + @page = params[:page]&.to_i || 1 + @per_page = params[:per_page]&.to_i || 24 + @total_pages = (@filtered_content.length.to_f / @per_page).ceil + @paginated_content = @filtered_content.slice((@page - 1) * @per_page, @per_page) || [] + + # Eager load image uploads for only the items being rendered + pages_to_preload = @paginated_content.map { |item| item[:page] }.select { |p| p.respond_to?(:image_uploads) } + ActiveRecord::Associations::Preloader.new.preload(pages_to_preload, :image_uploads) if pages_to_preload.any? + + # View mode + @view_mode = params[:view] || 'grid' + @view_mode = 'grid' unless %w[grid list timeline].include?(@view_mode) + end + + private + + def apply_content_filters + @filtered_content = @enhanced_content + + # Search filter + if params[:search].present? + search_term = params[:search].downcase + @filtered_content = @filtered_content.select do |item| + item[:page].name.downcase.include?(search_term) + end + end + + # Content type filter + if params[:content_type].present? && params[:content_type] != 'all' + @filtered_content = @filtered_content.select do |item| + item[:page].page_type == params[:content_type] + end + end + + # Date range filter + if params[:date_range].present? + case params[:date_range] + when 'today' + @filtered_content = @filtered_content.select { |item| item[:days_since_updated] == 0 } + when 'week' + @filtered_content = @filtered_content.select { |item| item[:days_since_updated] <= 7 } + when 'month' + @filtered_content = @filtered_content.select { |item| item[:days_since_updated] <= 30 } + when 'year' + @filtered_content = @filtered_content.select { |item| item[:days_since_updated] <= 365 } + end + end + + # Action filter (created vs updated) + if params[:action_filter].present? && params[:action_filter] != 'all' + @filtered_content = @filtered_content.select do |item| + item[:action] == params[:action_filter] + end + end + end + + def apply_content_sorting + sort_by = params[:sort] || 'updated_at' + sort_direction = params[:direction] || 'desc' + + @filtered_content = @filtered_content.sort_by do |item| + case sort_by + when 'name' + item[:page].name.downcase + when 'created_at' + item[:page].created_at + when 'updated_at' + item[:page].updated_at + when 'content_type' + item[:page].page_type + when 'edit_count' + item[:edit_count] + when 'word_count' + item[:word_count] + else + item[:page].updated_at + end + end + + @filtered_content.reverse! if sort_direction == 'desc' + end + + def generate_activity_analytics + @total_content = @enhanced_content.length + @total_edits = @enhanced_content.sum { |item| item[:edit_count] } + @total_words = @enhanced_content.sum { |item| item[:word_count] } + + # Content type breakdown + @content_type_stats = @enhanced_content.group_by { |item| item[:page].page_type } + .transform_values(&:count) + .sort_by { |_, count| -count } + + # Activity over time (last 7 days for recent content page) + @daily_activity = (0..6).map do |days_ago| + date = Date.current - days_ago.days + activity_count = @enhanced_content.count do |item| + item[:page].updated_at.to_date == date + end + [date.strftime('%m/%d'), activity_count] + end.reverse + + # Most active content types + @most_active_types = @enhanced_content.group_by { |item| item[:page].page_type } + .transform_values { |items| items.sum { |item| item[:edit_count] } } + .sort_by { |_, edits| -edits } + .first(5) + + # Recent activity summary + @recent_summary = { + today: @enhanced_content.count { |item| item[:days_since_updated] == 0 }, + this_week: @enhanced_content.count { |item| item[:days_since_updated] <= 7 }, + this_month: @enhanced_content.count { |item| item[:days_since_updated] <= 30 } + } + end + + def generate_dashboard_analytics + return unless user_signed_in? + + cache_current_user_content + + # Use user's timezone for date calculations + user_today = current_user.current_date_in_time_zone + + # 30-Day Activity Chart for Dashboard + # Batch fetch all 30 days of word counts in a single efficient query + dates = (0..29).map { |days_ago| user_today - days_ago.days } + @word_counts_by_date = WordCountUpdate.batch_words_written_on_dates(current_user, dates) + + @dashboard_daily_activity = dates.map do |date| + [date.strftime('%m/%d'), @word_counts_by_date[date] || 0] + end.reverse + + # Calculate Writing Streak (uses separate batch query for 365 days) + calculate_writing_streak + + # Words written today - reuse data from the batch query + @words_written_today = @word_counts_by_date[user_today] || 0 + + # Daily word goal (max of active goals or default 1,000) + @daily_word_goal = current_user.daily_word_goal + + # Words written this week (actual delta from end of last week) + @words_written_this_week = WordCountUpdate.words_written_in_range( + current_user, + user_today.beginning_of_week..user_today + ) + end + + def calculate_writing_streak + # Use user's timezone for date calculations + user_today = current_user.current_date_in_time_zone + + # Fetch all dates with writing activity in last 365 days in a single efficient query + dates_with_activity = WordCountUpdate.dates_with_writing_activity( + current_user, + user_today - 365.days, + user_today + ) + + # Calculate current streak from the set of dates + @current_streak = 0 + check_date = user_today + + # Allow for "today has no activity yet" case - start checking from yesterday + check_date -= 1.day unless dates_with_activity.include?(user_today) + + while dates_with_activity.include?(check_date) && @current_streak < 365 + @current_streak += 1 + check_date -= 1.day + end + + # Calculate total words written in current streak + @streak_total_words = 0 + if @current_streak > 0 + streak_start = check_date + 1.day # check_date is now one day before streak started + @streak_total_words = WordCountUpdate.words_written_in_range( + current_user, + streak_start..user_today + ) + end + + # Generate last 7 days for streak visualization + # Reuse data from @word_counts_by_date (30-day batch) if available, otherwise fetch + last_7_days = (0..6).map { |days_ago| user_today - days_ago.days } + word_counts_7_days = @word_counts_by_date || WordCountUpdate.batch_words_written_on_dates(current_user, last_7_days) + + @streak_days = last_7_days.map do |day_date| + words = word_counts_7_days[day_date] || 0 + { + date: day_date, + has_activity: words > 0, + word_count: words, + day_name: day_date.strftime('%a')[0] # First letter of day name + } + end.reverse end def for_writers @@ -104,7 +432,27 @@ def privacyinfo private def set_questionable_content - @content = @current_user_content.except(*%w(Timeline Document)).values.flatten.sample - @attribute_field_to_question = SerendipitousService.question_for(@content) + content_pool = @current_user_content.except(*%w(Timeline Document)).values.flatten.shuffle + + # Try to find content with an unanswered question + content_pool.each do |content| + question = SerendipitousService.question_for(content) + if question.present? + @content = content + @attribute_field_to_question = question + return + end + end + + # No unanswered questions found - set flags for empty state + @content = nil + @attribute_field_to_question = nil + end + + def set_page_meta_tags + set_meta_tags( + site: "The smart notebook for worldbuilders - Notebook.ai", + page: '' + ) end end diff --git a/app/controllers/moderation_controller.rb b/app/controllers/moderation_controller.rb new file mode 100644 index 000000000..5139c32fa --- /dev/null +++ b/app/controllers/moderation_controller.rb @@ -0,0 +1,17 @@ +class ModerationController < ApplicationController + before_action :authenticate_user! + before_action :require_moderator_access, unless: -> { Rails.env.development? } + + def hub + @reported_shares_count = ContentPageShareReport.where(approved_at: nil).count + @pending_forum_posts_count = Thredded::Post.pending_moderation.count + end + + private + + def require_moderator_access + unless user_signed_in? && current_user.forum_moderator + redirect_to root_path, notice: "You don't have permission to view that!" + end + end +end diff --git a/app/controllers/notifications_controller.rb b/app/controllers/notifications_controller.rb index 8ab3843cc..2934a41ed 100644 --- a/app/controllers/notifications_controller.rb +++ b/app/controllers/notifications_controller.rb @@ -1,4 +1,6 @@ class NotificationsController < ApplicationController + before_action :authenticate_user! + def index @notifications = current_user.notifications.order('happened_at DESC').limit(100) end diff --git a/app/controllers/page_collection_editor_picks_controller.rb b/app/controllers/page_collection_editor_picks_controller.rb new file mode 100644 index 000000000..4bd63dc7a --- /dev/null +++ b/app/controllers/page_collection_editor_picks_controller.rb @@ -0,0 +1,80 @@ +class PageCollectionEditorPicksController < ApplicationController + before_action :authenticate_user! + before_action :set_page_collection + before_action :require_collection_ownership + + # GET /collections/:page_collection_id/editor_picks + def index + @current_picks = @page_collection.editor_picks_ordered.includes({content: [:universe, :user], user: []}) + @available_submissions = @page_collection.accepted_submissions + .where(editor_pick_position: nil) + .includes({content: [:universe, :user], user: []}) + .order('accepted_at DESC') + end + + # POST /collections/:page_collection_id/editor_picks + def create + @submission = @page_collection.page_collection_submissions.find(params[:submission_id]) + + # Find the next available position + current_positions = @page_collection.editor_picks_ordered.pluck(:editor_pick_position) + next_position = (1..6).find { |pos| !current_positions.include?(pos) } + + if next_position && @submission.update(editor_pick_position: next_position) + render json: { + success: true, + message: "Added to Editor's Picks in position #{next_position}", + position: next_position + } + else + render json: { + success: false, + message: "Unable to add to Editor's Picks. Maximum of 6 picks allowed." + } + end + end + + # PATCH /collections/:page_collection_id/editor_picks/:id + def update + @submission = @page_collection.page_collection_submissions.find(params[:id]) + new_position = params[:position].to_i + + if (1..6).include?(new_position) + # Handle position swapping if needed + existing_submission = @page_collection.page_collection_submissions + .find_by(editor_pick_position: new_position) + + if existing_submission && existing_submission != @submission + existing_submission.update(editor_pick_position: @submission.editor_pick_position) + end + + @submission.update(editor_pick_position: new_position) + render json: { success: true, message: "Position updated successfully" } + else + render json: { success: false, message: "Invalid position" } + end + end + + # DELETE /collections/:page_collection_id/editor_picks/:id + def destroy + @submission = @page_collection.page_collection_submissions.find(params[:id]) + + if @submission.update(editor_pick_position: nil) + render json: { success: true, message: "Removed from Editor's Picks" } + else + render json: { success: false, message: "Unable to remove from Editor's Picks" } + end + end + + private + + def set_page_collection + @page_collection = PageCollection.find(params[:page_collection_id]) + end + + def require_collection_ownership + unless @page_collection.user == current_user + redirect_to @page_collection, alert: "You don't have permission to manage this collection's editor picks." + end + end +end \ No newline at end of file diff --git a/app/controllers/page_collection_submissions_controller.rb b/app/controllers/page_collection_submissions_controller.rb index 7dab6782e..85fbcbe81 100644 --- a/app/controllers/page_collection_submissions_controller.rb +++ b/app/controllers/page_collection_submissions_controller.rb @@ -24,17 +24,34 @@ def edit # POST /page_collection_submissions def create - @page_collection_submission = PageCollectionSubmission.new(page_collection_submission_params) - - if @page_collection_submission.save - if @page_collection_submission.page_collection.try(:privacy) == 'public' - @page_collection_submission.content.update(privacy: 'public') + begin + @page_collection_submission = PageCollectionSubmission.new(page_collection_submission_params) + + if @page_collection_submission.save + if @page_collection_submission.page_collection.try(:privacy) == 'public' + @page_collection_submission.content.update(privacy: 'public') + end + + redirect_to @page_collection_submission.page_collection, notice: 'Page submitted!' + else + page_collection = PageCollection.find(params[:page_collection_submission][:page_collection_id]) + redirect_to page_collection, alert: 'There was a problem submitting your page. Please try again.' + end + rescue ActionController::ParameterMissing => e + page_collection = PageCollection.find(params[:page_collection_submission][:page_collection_id]) rescue nil + if page_collection + redirect_to page_collection, alert: 'Please select content to submit.' + else + redirect_to page_collections_path, alert: 'Invalid submission.' + end + rescue => e + Rails.logger.error "PageCollectionSubmission creation failed: #{e.message}" + page_collection = PageCollection.find(params[:page_collection_submission][:page_collection_id]) rescue nil + if page_collection + redirect_to page_collection, alert: 'There was a problem submitting your page. Please try again.' + else + redirect_to page_collections_path, alert: 'Invalid submission.' end - - redirect_to @page_collection_submission.page_collection, notice: 'Page submitted!' - else - raise "failed create" - # render :new end end @@ -100,9 +117,20 @@ def set_page_collection_submission # Only allow a trusted parameter "white list" through. def page_collection_submission_params + content_param = params.require(:page_collection_submission).fetch(:content, '') + + if content_param.blank? + raise ActionController::ParameterMissing.new(:content) + end + + content_parts = content_param.split('-') + if content_parts.length != 2 + raise ActionController::BadRequest.new("Invalid content format") + end + { - content_type: params.require(:page_collection_submission).require(:content).split('-').first, - content_id: params.require(:page_collection_submission).require(:content).split('-').second, + content_type: content_parts.first, + content_id: content_parts.second, explanation: params.require(:page_collection_submission).fetch(:explanation, ''), user_id: current_user.id, submitted_at: DateTime.current, diff --git a/app/controllers/page_collections_controller.rb b/app/controllers/page_collections_controller.rb index 37e5b3cc3..ebfe46dac 100644 --- a/app/controllers/page_collections_controller.rb +++ b/app/controllers/page_collections_controller.rb @@ -4,7 +4,7 @@ class PageCollectionsController < ApplicationController before_action :set_sidenav_expansion before_action :set_navbar_color - before_action :set_page_collection, only: [:show, :edit, :by_user, :update, :destroy, :follow, :unfollow, :report] + before_action :set_page_collection, only: [:show, :edit, :by_user, :update, :destroy, :follow, :unfollow, :report, :rss] before_action :set_submittable_content, only: [:show, :by_user] before_action :require_collection_ownership, only: [:edit, :update, :destroy] @@ -38,6 +38,9 @@ def show @page_title = "#{@page_collection.name} - a Collection" @pages = @page_collection.accepted_submissions.includes({content: [:universe, :user], user: []}) + @contributors = User.where(id: @pages.to_a.map(&:user_id) - [@page_collection.user_id]) + @editor_picks = @page_collection.editor_picks_ordered.includes({content: [:universe, :user], user: []}) + sort_pages end @@ -113,6 +116,9 @@ def explore @show_page_type_highlight = true @page_type = content_type @pages = @page_collection.accepted_submissions.where(content_type: content_type.name).includes({content: [:universe, :user], user: []}) + @contributors = User.where(id: @pages.to_a.map(&:user_id) - [@page_collection.user_id]) + # Filter editor's picks to only show the current content type + @editor_picks = @page_collection.editor_picks_ordered.where(content_type: content_type.name).includes({content: [:universe, :user], user: []}) sort_pages render :show @@ -149,6 +155,51 @@ def by_user render :show end + def rss + unless (@page_collection.privacy == 'public' || (user_signed_in? && @page_collection.user == current_user)) + return redirect_to page_collections_path, notice: "That Collection is not public." + end + + @pages = @page_collection.accepted_submissions.includes({content: [:universe, :user], user: []}).limit(50) + + # Set the response content type explicitly + response.headers['Content-Type'] = 'application/rss+xml; charset=utf-8' + + render layout: false, template: 'page_collections/rss.rss.builder' + end + + # GET /page_collections/:id/pages + # AJAX endpoint for infinite scrolling + def pages + set_page_collection + + unless (@page_collection.privacy == 'public' || (user_signed_in? && @page_collection.user == current_user)) + return render json: { error: "Not authorized" }, status: :unauthorized + end + + page = params[:page].to_i || 1 + per_page = 5 # Number of items per page + + # Get paginated submissions + @pages = @page_collection.accepted_submissions + .includes({content: [:universe, :user], user: []}) + .offset((page - 1) * per_page) + .limit(per_page) + + sort_pages + + # Render each page as HTML and return as JSON + page_html = @pages.map do |submission| + render_to_string( + partial: 'page_collections/article', + locals: { submission: submission }, + layout: false + ) + end + + render json: { pages: page_html.map { |html| { html: html } } } + end + private def require_collection_ownership diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 894d1e49a..e95421edb 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -2,11 +2,23 @@ class RegistrationsController < Devise::RegistrationsController after_action :add_account, only: [:create] after_action :attach_avatar, only: [:update] + prepend_before_action :authenticate_scope!, only: [:edit, :update, :destroy, :password] before_action :set_navbar_actions, only: [:edit, :preferences, :more_actions] before_action :set_navbar_color, only: [:edit, :preferences, :more_actions] + layout :determine_layout + + def determine_layout + if action_name.in?(['new', 'create']) + 'tailwind/landing' + else + 'application' + end + end + def new super + if params[:referral] session[:referral] = params[:referral] end @@ -29,6 +41,15 @@ def more_actions @page_title = "More settings" end + + def password + @sidenav_expansion = 'my account' + + @page_title = "Change Password" + + # Set the resource for the form + self.resource = current_user + end private @@ -38,11 +59,11 @@ def sign_up_params def account_update_params params.require(:user).permit( - :name, :email, :username, :password, :password_confirmation, :email_updates, :fluid_preference, + :name, :email, :username, :password, :password_confirmation, :email_updates, :bio, :favorite_genre, :favorite_author, :interests, :age, :location, :gender, :forums_badge_text, :keyboard_shortcuts_preference, :avatar, :favorite_book, :website, :inspirations, :other_names, :favorite_quote, :occupation, :favorite_page_type, :dark_mode_enabled, :notification_updates, - :community_features_enabled, :private_profile + :community_features_enabled, :private_profile, :time_zone ) end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index c8045d036..046874f2c 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -2,13 +2,229 @@ class SearchController < ApplicationController before_action :authenticate_user! def results - @query = params[:q] + @query = params[:q]&.strip + @sort = params[:sort] || 'relevance' + @filter = params[:filter] || 'all' + # Return early if query is empty or too short + if @query.blank? || @query.length < 2 + @matched_attributes = Attribute.none + @result_types = [] + @seen_result_pages = {} + return + end + + # Search both Attribute values AND entity names (for content still in old-style columns) + + # 1. Search Attribute values (new-style fields) with multi-word support @matched_attributes = Attribute + .joins(:attribute_field) .where(user_id: current_user.id) - .where('value ILIKE ?', "%#{@query}%") - .order(:entity_type, :entity_id) + .where.not(value: [nil, '']) + + # Build multi-word search conditions for attributes + search_words = @query.split(/\s+/).reject(&:blank?) + if search_words.length > 1 + # Multi-word search: all words must be present (AND logic) + word_conditions = search_words.map { "LOWER(value) LIKE LOWER(?)" }.join(" AND ") + word_values = search_words.map { |word| "%#{word}%" } + @matched_attributes = @matched_attributes.where(word_conditions, *word_values) + else + # Single word search + @matched_attributes = @matched_attributes.where("LOWER(value) LIKE LOWER(?)", "%#{@query}%") + end + + # 2. Also search entity names (old-style name columns) with multi-word support + @matched_names = [] + (Rails.application.config.content_types[:all] + [Document]).each do |content_type| + begin + model_class = content_type.name.constantize + + # Determine the name column (Document uses 'title', others use 'name') + name_column = if model_class == Document + 'title' if model_class.column_names.include?('title') + else + 'name' if model_class.column_names.include?('name') + end + + if name_column + # Build multi-word search for entity names + if search_words.length > 1 + # Multi-word search: all words must be present in name/title + name_conditions = search_words.map { "LOWER(#{name_column}) LIKE LOWER(?)" }.join(" AND ") + name_values = search_words.map { |word| "%#{word}%" } + matching_entities = model_class + .where(user_id: current_user.id) + .where(name_conditions, *name_values) + else + # Single word search + matching_entities = model_class + .where(user_id: current_user.id) + .where("LOWER(#{name_column}) LIKE LOWER(?)", "%#{@query}%") + end + + matching_entities.each do |entity| + # Create a virtual attribute-like object for the name match + # Use the name method which returns title for Documents + @matched_names << OpenStruct.new( + id: "name_#{entity.class.name}_#{entity.id}", + entity_type: entity.class.name, + entity_id: entity.id, + value: entity.name, + attribute_field: OpenStruct.new(label: 'Name', field_type: 'name'), + created_at: entity.created_at, + updated_at: entity.updated_at + ) + end + end + rescue => e + # Skip any content types that don't exist or have issues + Rails.logger.debug "Skipping search in #{content_type.name}: #{e.message}" + end + end + + # Get result types for filtering (before pagination) + @result_types = (@matched_attributes.pluck(:entity_type) + @matched_names.map(&:entity_type)).uniq + + # Apply content type filter to both attribute results and name results + if @filter != 'all' + @matched_attributes = @matched_attributes.where(entity_type: @filter) + @matched_names.select! { |name_match| name_match.entity_type == @filter } + end + + # Combine both result sets for sorting and pagination + all_matches = @matched_attributes.to_a + @matched_names + + # Apply search refinement if provided + if params[:refine].present? + refine_query = params[:refine].strip + all_matches.select! do |match| + match.value.downcase.include?(refine_query.downcase) + end + end + + # Apply sorting to combined results + case @sort + when 'relevance' + # Order by relevance: exact matches first, then by entity type and id + all_matches.sort_by! do |match| + exact_match = match.value.downcase == @query.downcase ? 0 : 1 + [exact_match, match.entity_type, match.entity_id] + end + when 'recent' + all_matches.sort_by! { |match| -match.created_at.to_i } + when 'oldest' + all_matches.sort_by! { |match| match.created_at.to_i } + end + + # Add pagination to prevent performance issues with large result sets + page = [params[:page]&.to_i || 1, 1].max # Ensure page is at least 1 + per_page = 100 + start_index = (page - 1) * per_page + @matched_attributes = all_matches[start_index, per_page] || [] + + # Debug: Log search info in development + if Rails.env.development? + Rails.logger.info "=== Search Debug ===" + Rails.logger.info "Query: '#{@query}'" + Rails.logger.info "Attribute matches: #{@matched_attributes.respond_to?(:count) ? @matched_attributes.count : @matched_attributes.size}" + Rails.logger.info "Name matches: #{@matched_names.size}" + Rails.logger.info "Total results: #{all_matches.size}" + Rails.logger.info "Sample results: #{@matched_attributes.first(3).map { |m| "#{m.attribute_field.label}: '#{m.value}' (#{m.entity_type}##{m.entity_id})" }}" + end @seen_result_pages = {} end + + def autocomplete + @query = params[:q]&.strip + + # Return empty results for short or empty queries + if @query.blank? || @query.length < 2 + render json: [] + return + end + + results = [] + + # Fast name-only search across all content types + (Rails.application.config.content_types[:all] + [Document]).each do |content_type| + begin + model_class = content_type.name.constantize + + # Determine the name column (Document uses 'title', others use 'name') + name_column = if model_class == Document + 'title' if model_class.column_names.include?('title') + else + 'name' if model_class.column_names.include?('name') + end + + if name_column + # Fast query with limit to prevent slow searches + # Select the appropriate column and use .name method for display + select_columns = [:id, :created_at] + select_columns << (model_class == Document ? :title : :name) + + matching_entities = model_class + .where(user_id: current_user.id) + .where("LOWER(#{name_column}) LIKE LOWER(?)", "%#{@query}%") + .limit(5) # Limit per content type + .select(*select_columns) + + matching_entities.each do |entity| + results << { + id: entity.id, + name: entity.respond_to?(:name) ? entity.name : entity.title, + type: content_type.name, + icon: content_type.icon, + color: content_type.hex_color, + url: main_app.polymorphic_path(entity), + created_at: entity.created_at + } + end + end + rescue => e + # Skip any content types that have issues + Rails.logger.debug "Autocomplete: Skipping #{content_type.name}: #{e.message}" + end + + # Stop if we have enough results + break if results.size >= 15 + end + + # Sort by relevance: exact matches first, then by name + results.sort_by! do |result| + exact_match = result[:name].downcase == @query.downcase ? 0 : 1 + [exact_match, result[:name].downcase] + end + + # Limit total results + results = results.first(12) + + render json: results + end + + helper_method :highlight_search_terms + + private + + def highlight_search_terms(text, query) + return text if query.blank? + + words = query.split(/\s+/).reject(&:blank?) + highlighted = text + + words.each do |word| + highlighted = highlighted.gsub( + /\b#{Regexp.escape(word)}\b/i, + '\0' + ) + end + + highlighted.html_safe + end + + def search_params + params.permit(:q, :sort, :filter, :page, :refine) + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..196bf46e9 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,3 @@ +class SessionsController < Devise::SessionsController + layout 'tailwind/landing' +end \ No newline at end of file diff --git a/app/controllers/share_comments_controller.rb b/app/controllers/share_comments_controller.rb index 9e5cd209e..cb388ff8a 100644 --- a/app/controllers/share_comments_controller.rb +++ b/app/controllers/share_comments_controller.rb @@ -13,7 +13,7 @@ def create ContentPageShareNotificationJob.perform_later(@share_comment.id) - redirect_to([@share_comment.content_page_share.user, @share_comment.content_page_share], notice: "Comment posted successfully!"); + redirect_to([@share_comment.content_page_share.user, @share_comment.content_page_share], notice: "Thanks for leaving a comment!"); # redirect_back(fallback_location: @share_comment.content_page_share, notice: "Comment posted successfully.") else render :new diff --git a/app/controllers/stream_controller.rb b/app/controllers/stream_controller.rb index 3b650dc8c..fb71f0aad 100644 --- a/app/controllers/stream_controller.rb +++ b/app/controllers/stream_controller.rb @@ -3,7 +3,8 @@ class StreamController < ApplicationController before_action :set_stream_navbar_actions, only: [:index, :global] before_action :set_stream_navbar_color, only: [:index, :global] before_action :set_sidenav_expansion - before_action :cache_linkable_content_for_each_content_type, only: [:index] + before_action :cache_linkable_content_for_each_content_type, only: [:index, :global] + before_action :load_recent_forum_topics, only: [:index, :global] def index @page_title = "What's happening" @@ -16,7 +17,17 @@ def index .order('created_at DESC') .includes([:content_page, :secondary_content_page]) .includes({ share_comments: [:user], user: [:avatar_attachment] }) - .limit(25) + + # Apply search filter if present + if params[:search].present? + search_term = "%#{params[:search]}%" + @feed = @feed.joins(:user).where( + "content_page_shares.message ILIKE ? OR users.name ILIKE ? OR users.email ILIKE ?", + search_term, search_term, search_term + ) + end + + @feed = @feed.limit(25) end def community @@ -33,7 +44,17 @@ def global .order('created_at DESC') .includes([:content_page, :secondary_content_page]) .includes({ share_comments: [:user], user: [:avatar_attachment] }) - .limit(25) + + # Apply search filter if present + if params[:search].present? + search_term = "%#{params[:search]}%" + @feed = @feed.joins(:user).where( + "content_page_shares.message ILIKE ? OR users.name ILIKE ? OR users.email ILIKE ?", + search_term, search_term, search_term + ) + end + + @feed = @feed.limit(25) end def set_stream_navbar_color @@ -57,4 +78,19 @@ def set_stream_navbar_actions def set_sidenav_expansion @sidenav_expansion = 'community' end + + private + + def load_recent_forum_topics + # Get the 5 most recently active forum topics + @recent_forum_topics = Thredded::Topic + .where(deleted_at: nil) + .where.not(moderation_state: :blocked) + .order(last_post_at: :desc) + .limit(5) + .includes(:messageboard) + rescue => e + Rails.logger.error "Error loading recent forum topics: #{e.message}" + @recent_forum_topics = [] + end end diff --git a/app/controllers/styleguide_controller.rb b/app/controllers/styleguide_controller.rb new file mode 100644 index 000000000..0a6349966 --- /dev/null +++ b/app/controllers/styleguide_controller.rb @@ -0,0 +1,4 @@ +class StyleguideController < ApplicationController + def tailwind + end +end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 94bb3e5cb..557ac7501 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -17,6 +17,7 @@ def new @active_promo_code = @active_promotions.first.try(:page_unlock_promo_code) @stripe_customer = Stripe::Customer.retrieve(current_user.stripe_customer_id) + @stripe_payment_methods = @stripe_customer.list_payment_methods(type: 'card') end def history diff --git a/app/controllers/thredded/topics_controller.rb b/app/controllers/thredded/topics_controller.rb new file mode 100644 index 000000000..9f59cd22a --- /dev/null +++ b/app/controllers/thredded/topics_controller.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +# Override Thredded::TopicsController to fix N+1 queries +module Thredded + class TopicsController < Thredded::ApplicationController + include Thredded::NewTopicParams + include Thredded::NewPostParams + + before_action :thredded_require_login!, + only: %i[edit new update create destroy follow unfollow unread] + + before_action :verify_messageboard, + only: %i[index search unread] + + before_action :use_topic_messageboard, + only: %i[show edit update destroy follow unfollow] + + after_action :update_user_activity + + after_action :verify_authorized, except: %i[search unread] + after_action :verify_policy_scoped, except: %i[show new create edit update destroy follow unfollow] + + def index + page_scope = policy_scope(messageboard.topics) + .order_sticky_first.order_recently_posted_first + .includes(:categories, :last_user, :user) + .send(Kaminari.config.page_method_name, current_page) + return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope) + @topics = Thredded::TopicsPageView.new(thredded_current_user, page_scope) + @new_topic = init_new_topic + end + + def unread + page_scope = topics_scope + .unread(thredded_current_user) + .order_followed_first(thredded_current_user).order_recently_posted_first + .includes(:categories, :last_user, :user) + .send(Kaminari.config.page_method_name, current_page) + return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope) + @topics = Thredded::TopicsPageView.new(thredded_current_user, page_scope) + @new_topic = init_new_topic + end + + def search + @query = params[:q].to_s + page_scope = topics_scope + .search_query(@query) + .order_recently_posted_first + .includes(:categories, :last_user, :user) + .send(Kaminari.config.page_method_name, current_page) + return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope) + @topics = Thredded::TopicsPageView.new(thredded_current_user, page_scope) + end + + def show + authorize topic, :read? + return redirect_to(canonical_topic_params) unless params_match?(canonical_topic_params) + page_scope = policy_scope(topic.posts) + .order_oldest_first + # Include user with avatar attachment to prevent N+1 on post.user.image_url + # Include promotions to prevent N+1 on post.user.on_premium_plan? + .includes(user: [{ avatar_attachment: :blob }, :promotions]) + .includes(:messageboard) + .send(Kaminari.config.page_method_name, current_page) + return redirect_to(last_page_params(page_scope)) if page_beyond_last?(page_scope) + @posts = Thredded::TopicPostsPageView.new(thredded_current_user, topic, page_scope) + Thredded::UserTopicReadState.touch!(thredded_current_user.id, page_scope.last) if thredded_signed_in? + @new_post = Thredded::PostForm.new(user: thredded_current_user, topic: topic, post_params: new_post_params) + end + + def new + @new_topic = Thredded::TopicForm.new(new_topic_params) + authorize_creating @new_topic.topic + return redirect_to(canonical_messageboard_params) unless params_match?(canonical_messageboard_params) + render + end + + def category + authorize_reading messageboard + @category = messageboard.categories.friendly.find(params[:category_id]) + @topics = Thredded::TopicsPageView.new( + thredded_current_user, + policy_scope(@category.topics) + .unstuck + .order_recently_posted_first + .send(Kaminari.config.page_method_name, current_page) + ) + render :index + end + + def create + @new_topic = Thredded::TopicForm.new(new_topic_params) + authorize_creating @new_topic.topic + if @new_topic.save + redirect_to next_page_after_create(params[:next_page]) + else + render :new + end + end + + def edit + authorize topic, :update? + return redirect_to(canonical_topic_params) unless params_match?(canonical_topic_params) + @edit_topic = Thredded::EditTopicForm.new(user: thredded_current_user, topic: topic) + end + + def update + topic.assign_attributes(topic_params_for_update) + authorize topic, :update? + if topic.messageboard_id_changed? + topic.messageboard = Thredded::Messageboard.find(topic.messageboard_id) + authorize topic.messageboard, :post? + end + @edit_topic = Thredded::EditTopicForm.new(user: thredded_current_user, topic: topic) + if @edit_topic.save + redirect_to messageboard_topic_url(topic.messageboard, topic), + notice: t('thredded.topics.updated_notice') + else + render :edit + end + end + + def destroy + authorize topic, :destroy? + topic.destroy! + redirect_to messageboard_topics_path(messageboard), + notice: t('thredded.topics.deleted_notice') + end + + def follow + authorize topic, :read? + Thredded::UserTopicFollow.create_unless_exists(thredded_current_user.id, topic.id) + follow_change_response(following: true) + end + + def unfollow + authorize topic, :read? + Thredded::UserTopicFollow.find_by(topic_id: topic.id, user_id: thredded_current_user.id).try(:destroy) + follow_change_response(following: false) + end + + private + + def next_page_after_create(next_page) + case next_page + when 'messageboard', '', nil + return messageboard_topics_path(messageboard) + when 'topic' + messageboard_topic_path(messageboard, @new_topic.topic) + when %r{\A/[^/]\S+\z} + next_page + else + fail "Unexpected value for next page: #{next_page.inspect}" + end + end + + def in_messageboard? + params.key?(:messageboard_id) + end + + def init_new_topic + return unless in_messageboard? + form = Thredded::TopicForm.new(messageboard: messageboard, user: thredded_current_user) + form if policy(form.topic).create? + end + + def verify_messageboard + return unless in_messageboard? + authorize_reading messageboard + return if params_match?(canonical_messageboard_params) + skip_policy_scope + redirect_to(canonical_messageboard_params) + end + + def canonical_messageboard_params + { messageboard_id: messageboard.slug } + end + + def canonical_topic_params + { messageboard_id: messageboard.slug, id: topic.slug } + end + + def follow_change_response(following:) + notice = following ? t('thredded.topics.followed_notice') : t('thredded.topics.unfollowed_notice') + respond_to do |format| + format.html { redirect_to messageboard_topic_url(messageboard, topic), notice: notice } + format.json { render(json: { follow: following }) } + end + end + + def topic + @topic ||= Thredded::Topic.friendly_find!(params[:id]) + end + + def use_topic_messageboard + @messageboard = topic.messageboard + end + + def topic_params_for_update + params + .require(:topic) + .permit(:title, :locked, :sticky, :messageboard_id, category_ids: []) + end + + def current_page + (params[:page] || 1).to_i + end + end +end diff --git a/app/controllers/timeline_events_controller.rb b/app/controllers/timeline_events_controller.rb index 3f1ca7751..1034ef7c4 100644 --- a/app/controllers/timeline_events_controller.rb +++ b/app/controllers/timeline_events_controller.rb @@ -2,7 +2,7 @@ class TimelineEventsController < ApplicationController before_action :set_timeline_event, only: [ :show, :edit, :update, :destroy, :move_up, :move_to_top, :move_down, :move_to_bottom, - :link_entity, :unlink_entity + :link_entity, :unlink_entity, :add_tag, :remove_tag ] # GET /timeline_events @@ -27,10 +27,27 @@ def edit def create raise "No Access: (signed in: #{user_signed_in?})" unless user_signed_in? && current_user.timelines.pluck(:id).include?(timeline_event_params.fetch('timeline_id').to_i) - @timeline_event = TimelineEvent.new(timeline_event_params) + @timeline_event = TimelineEvent.new(timeline_event_params) if @timeline_event.save - render json: { status: 'success', id: @timeline_event.reload.id } + @timeline_event.reload + + # Render the event card partial as HTML for client-side injection + html = render_to_string( + partial: 'timeline_events/event_card', + locals: { + event: @timeline_event, + timeline: @timeline_event.timeline, + index: @timeline_event.timeline.timeline_events.count - 1 + }, + formats: [:html] + ) + + render json: { + status: 'success', + id: @timeline_event.id, + html: html + } else raise "Failed to create TimelineEvent" end @@ -39,11 +56,17 @@ def create # PATCH/PUT /timeline_events/1 def update if @timeline_event.update(timeline_event_params) - redirect_to @timeline_event, notice: 'Timeline event was successfully updated.' + respond_to do |format| + format.html { redirect_to @timeline_event, notice: 'Timeline event was successfully updated.' } + format.json { render json: { status: 'success', message: 'Timeline event updated successfully' } } + format.js { render json: { status: 'success', message: 'Timeline event updated successfully' } } + end else - require 'pry' - binding.pry - render json: :failure + respond_to do |format| + format.html { render :edit } + format.json { render json: { status: 'error', message: 'Failed to update timeline event', errors: @timeline_event.errors.full_messages }, status: :unprocessable_entity } + format.js { render json: { status: 'error', message: 'Failed to update timeline event', errors: @timeline_event.errors.full_messages }, status: :unprocessable_entity } + end end end @@ -55,13 +78,55 @@ def destroy end def link_entity - return unless @timeline_event.can_be_modified_by?(current_user) - @timeline_event.timeline_event_entities.find_or_create_by(timeline_event_entity_params) + return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user) + + entity = @timeline_event.timeline_event_entities.find_or_create_by(timeline_event_entity_params) + + if entity.persisted? + # Reload the timeline event to get the latest linked content + @timeline_event.reload + + render json: { + status: 'success', + message: 'Content linked successfully', + html: render_to_string( + partial: 'shared/timeline_event_linked_content', + locals: { timeline_event: @timeline_event } + ) + } + else + render json: { + status: 'error', + message: 'Failed to link content', + errors: entity.errors.full_messages + }, status: :unprocessable_entity + end end def unlink_entity - return unless @timeline_event.can_be_modified_by?(current_user) - @timeline_event.timeline_event_entities.find_by(id: params[:entity_id].to_i).try(:destroy) + return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user) + + entity = @timeline_event.timeline_event_entities.find_by(id: params[:entity_id].to_i) + + if entity&.destroy + # Reload the timeline event to get the latest linked content + @timeline_event.reload + + render json: { + status: 'success', + message: 'Content unlinked successfully', + html: render_to_string( + partial: 'shared/timeline_event_linked_content', + locals: { timeline_event: @timeline_event }, + formats: [:html] + ) + } + else + render json: { + status: 'error', + message: 'Failed to unlink content' + }, status: :unprocessable_entity + end end # Move functions @@ -81,6 +146,98 @@ def move_to_bottom @timeline_event.move_to_bottom if @timeline_event.can_be_modified_by?(current_user) end + # Drag and drop sorting endpoint (internal API) + def sort + content_id = params[:content_id] + intended_position = params[:intended_position].to_i + + timeline_event = TimelineEvent.find_by(id: content_id) + + unless timeline_event + render json: { error: "Timeline event not found" }, status: :not_found + return + end + + unless timeline_event.can_be_modified_by?(current_user) + render json: { error: "You don't have permission to reorder that timeline event" }, status: :forbidden + return + end + + # Use acts_as_list to move to the intended position + timeline_event.insert_at(intended_position + 1) # acts_as_list is 1-indexed + + render json: { + success: true, + message: "New position saved", # "Timeline event moved to position #{intended_position + 1}" + timeline_event: { + id: timeline_event.id, + position: timeline_event.position, + title: timeline_event.title + } + }, status: :ok + end + + def add_tag + return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user) + + tag_name = params[:tag_name]&.strip + return render json: { error: 'Tag name is required' }, status: :bad_request if tag_name.blank? + + # Check if tag already exists for this event + existing_tag = @timeline_event.page_tags.find_by(tag: tag_name) + if existing_tag + return render json: { + status: 'error', + message: 'Tag already exists for this event' + }, status: :unprocessable_entity + end + + # Create the tag + tag = @timeline_event.page_tags.create( + tag: tag_name, + slug: PageTagService.slug_for(tag_name), + user: current_user + ) + + if tag.persisted? + render json: { + status: 'success', + message: 'Tag added successfully', + tag: { + id: tag.id, + name: tag.tag + } + } + else + render json: { + status: 'error', + message: 'Failed to add tag', + errors: tag.errors.full_messages + }, status: :unprocessable_entity + end + end + + def remove_tag + return render json: { error: 'Not authorized' }, status: :forbidden unless @timeline_event.can_be_modified_by?(current_user) + + tag_name = params[:tag_name]&.strip + return render json: { error: 'Tag name is required' }, status: :bad_request if tag_name.blank? + + tag = @timeline_event.page_tags.find_by(tag: tag_name) + + if tag&.destroy + render json: { + status: 'success', + message: 'Tag removed successfully' + } + else + render json: { + status: 'error', + message: 'Tag not found or failed to remove' + }, status: :unprocessable_entity + end + end + private # Use callbacks to share common setup or constraints between actions. @@ -90,7 +247,8 @@ def set_timeline_event # Only allow a trusted parameter "white list" through. def timeline_event_params - params.require(:timeline_event).permit(:time_label, :title, :description, :notes, :timeline_id) + params.require(:timeline_event).permit(:time_label, :title, :description, :notes, :timeline_id, + :event_type, :importance_level, :end_time_label, :status, :private_notes) end def timeline_event_entity_params diff --git a/app/controllers/timelines_controller.rb b/app/controllers/timelines_controller.rb index 2c67807de..ef8383ed3 100644 --- a/app/controllers/timelines_controller.rb +++ b/app/controllers/timelines_controller.rb @@ -1,6 +1,8 @@ class TimelinesController < ApplicationController + include ApplicationHelper + before_action :authenticate_user!, except: [:show] - before_action :set_timeline, only: [:show, :edit, :update, :destroy] + before_action :set_timeline, only: [:show, :edit, :update, :destroy, :toggle_archive, :toggle_favorite] before_action :set_navbar_color before_action :set_sidenav_expansion @@ -18,17 +20,20 @@ def index # without reworking most of the views. For now, we're just grabbing timelines and contributable # timelines manually. # @timelines = @linkables_raw.fetch('Timeline', []) - @timelines = current_user.timelines + @timelines = current_user.timelines.includes(:timeline_events) @page_title = "My timelines" if @universe_scope - @timelines = Timeline.where(universe: @universe_scope) + @timelines = Timeline.where(universe: @universe_scope).includes(:timeline_events) else # Add in all timelines from shared universes also @timelines += Timeline.where(universe_id: current_user.contributable_universe_ids) + .where.not(id: @timelines.pluck(:id)) + .includes(:timeline_events) end + @filtered_page_tags = [] @page_tags = PageTag.where( page_type: Timeline.name, page_id: @timelines.pluck(:id) @@ -41,6 +46,36 @@ def index # if params.key?(:favorite_only) # @timelines.select!(&:favorite?) # end + + # Precompute timeline event statistics using already-loaded associations + @timeline_event_counts = {} + @total_events = 0 + @event_type_counts = Hash.new(0) + + @timelines.each do |timeline| + events = timeline.timeline_events + count = events.size # Uses loaded data, no query + @timeline_event_counts[timeline.id] = count + @total_events += count + + events.each do |event| + type = event.respond_to?(:event_type) ? (event.event_type || 'Unspecified') : 'Unspecified' + @event_type_counts[type] += 1 + end + end + + # New style, using content#index view (we can wipe unused stuff from above once this is finalized) + @content_type_class = Timeline + @content_type_name = @content_type_class.name + @content = @timelines + @total_content_count = @timelines.count + + # Set up pagination variables (no actual pagination for now, but template expects them) + @current_page = 1 + @total_pages = 1 + + @folders = [] + render 'timelines/index' end def show @@ -49,7 +84,7 @@ def show # GET /timelines/new def new - timeline = current_user.timelines.create(name: 'Untitled Timeline', universe: @universe_scope).reload + timeline = current_user.timelines.create(name: '', universe: @universe_scope).reload redirect_to edit_timeline_path(timeline) end @@ -57,6 +92,44 @@ def new def edit @page_title = "Editing #{@timeline.name}" @suggested_page_tags = [] + cache_linkable_content_for_each_content_type + + # Collect all unique tags from timeline events for filtering + @timeline_event_tags = collect_timeline_event_tags + + # Get content already linked to other events in this timeline + @timeline_linked_content = {} + @timeline_content_summary = {} + timeline_entity_ids = @timeline.timeline_events + .joins(:timeline_event_entities) + .pluck('timeline_event_entities.entity_type', 'timeline_event_entities.entity_id') + .uniq + + timeline_entity_ids.group_by(&:first).each do |entity_type, entity_pairs| + entity_ids = entity_pairs.map(&:last).uniq + content_class = content_class_from_name(entity_type) + next unless content_class + + # Original data for link modal + @timeline_linked_content[entity_type] = content_class.where(id: entity_ids) + .where(user: current_user) + .limit(20) + + # Enhanced data for content summary sidebar + # Sort in Ruby instead of SQL because some models (e.g. Document) don't have + # a 'name' column - they have 'title' with a Ruby method aliasing it to 'name' + all_content = content_class.where(id: entity_ids) + .where(user: current_user) + .to_a + .sort_by { |c| c.name.to_s.downcase } + + @timeline_content_summary[entity_type] = { + count: all_content.count, + items: all_content.first(10), # Show first 10, with expansion option + total_items: all_content.count, + content_class: content_class + } + end end # POST /timelines @@ -90,6 +163,82 @@ def destroy redirect_to timelines_url, notice: 'Timeline was successfully destroyed.' end + # POST /timelines/1/toggle_archive + def toggle_archive + unless @timeline.updatable_by?(current_user) + respond_to do |format| + format.html do + flash[:notice] = "You don't have permission to edit that!" + redirect_back fallback_location: @timeline + end + format.json { render json: { success: false, error: "Permission denied" }, status: :forbidden } + end + return + end + + verb = @timeline.archived? ? "unarchived" : "archived" + success = @timeline.archived? ? @timeline.unarchive! : @timeline.archive! + + respond_to do |format| + if success + format.html do + flash[:notice] = "Timeline #{verb} successfully!" + redirect_back fallback_location: edit_timeline_path(@timeline) + end + format.json { render json: { success: true, archived: @timeline.archived?, verb: verb } } + else + format.html do + flash[:alert] = "Failed to #{verb.sub('ed', '')} timeline." + redirect_back fallback_location: edit_timeline_path(@timeline) + end + format.json { render json: { success: false, error: "Failed to #{verb}" }, status: :unprocessable_entity } + end + end + end + + # POST /timelines/1/toggle_favorite + def toggle_favorite + unless @timeline.updatable_by?(current_user) + respond_to do |format| + format.html do + flash[:notice] = "You don't have permission to edit that!" + redirect_back fallback_location: @timeline + end + format.json { render json: { success: false, error: "Permission denied" }, status: :forbidden } + end + return + end + + @timeline.update(favorite: !@timeline.favorite) + + respond_to do |format| + format.html { redirect_back(fallback_location: timelines_path) } + format.json { render json: { success: true, favorite: @timeline.favorite } } + end + end + + # GET /timelines/1/tag_suggestions + def tag_suggestions + # Get all unique tags from both Timeline objects and TimelineEvent objects + # This includes tags from the timeline itself AND all its events + timeline_tags = PageTag.where( + user: current_user, + page_type: 'Timeline' + ).distinct.pluck(:tag) + + event_tags = PageTag.where( + user: current_user, + page_type: 'TimelineEvent' + ).distinct.pluck(:tag) + + # Combine and sort all unique tags + all_tags = (timeline_tags + event_tags).uniq.sort + + render json: { + suggestions: all_tags + } + end + private def require_timeline_read_permission @@ -142,4 +291,17 @@ def set_navbar_color def set_sidenav_expansion @sidenav_expansion = 'writing' end + + def collect_timeline_event_tags + # Get all unique tags from timeline events with their usage counts + tag_counts = @timeline.timeline_events + .reorder('') + .joins(:page_tags) + .group('page_tags.tag') + .count + + # Return array of hashes with tag name and count for easy access in view + tag_counts.map { |tag, count| { name: tag, count: count } } + .sort_by { |tag_data| tag_data[:name].downcase } + end end diff --git a/app/controllers/universes_controller.rb b/app/controllers/universes_controller.rb index 8eb038109..e238b72cf 100644 --- a/app/controllers/universes_controller.rb +++ b/app/controllers/universes_controller.rb @@ -1,4 +1,20 @@ class UniversesController < ContentController + def hub + # If the user came from a local page that is NOT the multiverse hub itself, store it as the return_to target + if request.referer.present? + begin + referer_uri = URI(request.referer) + # Verify the referer is from our domain and isn't just the multiverse page reloaded + if referer_uri.host == request.host && referer_uri.path != multiverse_path + @return_to = request.referer + end + rescue URI::InvalidURIError + # ignore invalid referers + end + end + + @universes = @current_user_content.fetch('Universe', []).sort_by(&:name) + end # TODO: pull list of content types out from some centralized list somewhere (Rails.application.config.content_types[:all_non_universe] + [Timeline]).each do |content_type| diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e7ac5d9ce..44d8a0e15 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -8,19 +8,12 @@ def index def show @sidenav_expansion = 'community' - @feed = ContentPageShare.where(user_id: @user.id) - .order('created_at DESC') - .includes([:content_page, :secondary_content_page, :user, :share_comments]) - .limit(100) - - @content = @user.public_content.select { |type, list| list.any? } - @tabs = @content.keys - - # Get popular tags for this user's public content - @popular_tags = get_popular_public_tags_for_user(@user) - - @favorite_content = @user.favorite_page_type? ? @user.send(@user.favorite_page_type.downcase.pluralize).is_public : [] - @stream = @user.recent_content_list(limit: 20) + # Load all profile data + load_user_content + load_user_activity + load_user_social_data + load_user_collections + load_user_statistics end Rails.application.config.content_types[:all].each do |content_type| @@ -28,27 +21,67 @@ def show define_method content_type_name do set_user - # We double up on the same returns from set_user here since we're calling set_user manually instead of a before_action - return if @user.nil? - return if @user.private_profile? + # We return early if set_user already rendered or redirected + return if performed? - @random_image_including_private_pool_cache = ImageUpload.where( - user_id: @user.id, - ).group_by { |image| [image.content_type, image.content_id] } - + # Optimized: Only load images for content that will be displayed @content_type = content_type - @content_list = @user.send(content_type_name).is_public.order(:name) + @content_list = @user.send(content_type_name) + .includes(:page_tags, :image_uploads, :user) + .is_public + .order(:name) + .paginate(page: params[:page], per_page: 20) - @saved_basil_commissions = BasilCommission.where( - entity_type: content_type_name, - entity_id: @content_list.pluck(:id) - ).where.not(saved_at: nil) - .group_by { |commission| [commission.entity_type, commission.entity_id] } + # Only load public images for the content being displayed + content_ids = @content_list.pluck(:id) + @random_image_including_private_pool_cache = ImageUpload + .where(user_id: @user.id, content_type: content_type.name, content_id: content_ids) + .where(privacy: 'public') + .group_by { |image| [image.content_type, image.content_id] } + + # Optimized: Use content_ids we already have + @saved_basil_commissions = BasilCommission + .where(entity_type: content_type.name, entity_id: content_ids) + .where.not(saved_at: nil) + .group_by { |commission| [commission.entity_type, commission.entity_id] } render :content_list end end + # Timeline isn't in content_types[:all] but some users have it as favorite_page_type + def timelines + set_user + return if performed? + + @content_type = Timeline + @content_list = @user.timelines + .includes(:page_tags, :user) + .where(privacy: 'public') + .order(:name) + .paginate(page: params[:page], per_page: 20) + + # Timeline doesn't use image_uploads or basil commissions like content pages + @random_image_including_private_pool_cache = {} + @saved_basil_commissions = {} + + render :content_list + end + + # Silent timezone auto-update for legacy users (remove after Jan 31, 2026) + def update_timezone + return head :unauthorized unless user_signed_in? + + timezone = params[:time_zone] + + if ActiveSupport::TimeZone[timezone].present? + current_user.update(time_zone: timezone) + head :ok + else + head :unprocessable_entity + end + end + def delete_my_account # :( unless user_signed_in? redirect_to(root_path, notice: "You must be signed in to do that!") @@ -82,6 +115,8 @@ def delete_my_account # :( # TODO this can take quite a while for active users, so it should be moved to a background job current_user.destroy! + sign_out(current_user) + redirect_to(root_path, notice: 'Your account has been deleted. We will miss you greatly!') end @@ -98,15 +133,25 @@ def report_user_deletion_to_slack user end def followers + @followers = @user.followed_by_users + .includes(:avatar_attachment, :thredded_user_detail) + .paginate(page: params[:page], per_page: 100) end def following + @following = @user.followed_users + .includes(:avatar_attachment, :thredded_user_detail) + .paginate(page: params[:page], per_page: 100) end def tag @tag_slug = params[:tag_slug] - @tag = PageTag.find_by(user_id: @user.id, slug: @tag_slug) - + + # Slugs are always stored lowercase, so direct equality works + @tag = PageTag.where(user_id: @user.id) + .where(slug: @tag_slug.downcase) + .first + if @tag.nil? redirect_url = @user.username.present? ? profile_by_username_path(username: @user.username) : user_path(@user) return redirect_to(redirect_url, notice: 'That tag does not exist.', status: :not_found) @@ -114,51 +159,70 @@ def tag # Find all public content with this tag @tagged_content = [] - + # Go through each content type and find items with this tag + # Use two-step approach: first get page IDs, then query content (matches BrowseController pattern) Rails.application.config.content_types[:all].each do |content_type| - content_pages = content_type.joins(:page_tags) - .where(privacy: 'public') - .where(user_id: @user.id) - .where(page_tags: { slug: @tag_slug }) - .order(:name) + tag_page_ids = PageTag.where(user_id: @user.id) + .where(page_type: content_type.name) + .where(slug: @tag_slug.downcase) + .pluck(:page_id) - @tagged_content << { - type: content_type.name, - icon: content_type.icon, - color: content_type.color, - content: content_pages - } if content_pages.any? + if tag_page_ids.any? + content_pages = content_type.where(id: tag_page_ids) + .where(privacy: 'public') + .where(user_id: @user.id) + .order(:name) + + @tagged_content << { + type: content_type.name, + icon: content_type.icon, + color: content_type.color, + content: content_pages + } if content_pages.any? + end end - + # Add documents and timelines if they have the tag # Handle documents separately since they don't use the common content type structure - documents = Document.joins(:page_tags) - .where(privacy: 'public') - .where(user_id: @user.id) - .where(page_tags: { slug: @tag_slug }) - .order(:title) # Documents use 'title' instead of 'name' - - @tagged_content << { - type: 'Document', - icon: 'description', - color: 'blue', - content: documents - } if documents.any? - + document_page_ids = PageTag.where(user_id: @user.id) + .where(page_type: 'Document') + .where(slug: @tag_slug.downcase) + .pluck(:page_id) + + if document_page_ids.any? + documents = Document.where(id: document_page_ids) + .where(privacy: 'public') + .where(user_id: @user.id) + .order(:title) # Documents use 'title' instead of 'name' + + @tagged_content << { + type: 'Document', + icon: 'description', + color: 'blue', + content: documents + } if documents.any? + end + # Handle timelines separately since they don't use the common content type structure - timelines = Timeline.joins(:page_tags) - .where(privacy: 'public') - .where(user_id: @user.id) - .where(page_tags: { slug: @tag_slug }) - .order(:name) - - @tagged_content << { - type: 'Timeline', - icon: 'timeline', - color: 'blue', - content: timelines - } if timelines.any? + timeline_page_ids = PageTag.where(user_id: @user.id) + .where(page_type: 'Timeline') + .where(slug: @tag_slug.downcase) + .pluck(:page_id) + + if timeline_page_ids.any? + timelines = Timeline.where(id: timeline_page_ids) + .where(privacy: 'public') + .where(user_id: @user.id) + .order(:name) + + @tagged_content << { + type: 'Timeline', + icon: 'timeline', + color: 'blue', + content: timelines + } if timelines.any? + end # Get images for content cards @random_image_including_private_pool_cache = ImageUpload.where( @@ -204,6 +268,156 @@ def user_params params.permit(:id, :username) end + # Data loading methods for profile sections + + def load_user_content + @content = @user.public_content.select { |type, list| list.any? } + @tabs = @content.keys + @popular_tags = get_popular_public_tags_for_user(@user) + @favorite_content = @user.favorite_page_type? ? @user.send(@user.favorite_page_type.downcase.pluralize).is_public : [] + + # Get featured universes (top 3 most recently updated) + @featured_universes = @user.universes.is_public.order(updated_at: :desc).limit(3) if @user.respond_to?(:universes) + @featured_universes ||= [] + end + + def load_user_activity + # Optimized: Use joins to filter at database level + @feed = ContentPageShare + .includes(:content_page, :share_comments) + .where(user_id: @user.id) + .where(privacy: 'public') + .order('created_at DESC') + .limit(100) + + # Optimized: Get public content directly + @stream = @user.recent_public_content_list(limit: 20) + + # Skip recent edits - we don't want to show "made an edit" activities + @recent_edits = [] + + # Forum activity (if Thredded is available) + if defined?(Thredded::Post) + @recent_forum_posts = Thredded::Post + .includes(:postable, :messageboard) + .where(user_id: @user.id, moderation_state: 'approved') + .order(created_at: :desc) + .limit(10) + else + @recent_forum_posts = [] + end + + # Combine all activities for unified timeline (excluding edits) + @unified_activity = build_unified_activity_timeline + end + + def load_user_social_data + # Optimized: Use counter caches if available, fallback to count + @followers_count = @user.respond_to?(:followers_count) ? @user.followers_count : @user.followed_by_users.count + @following_count = @user.respond_to?(:following_count) ? @user.following_count : @user.followed_users.count + + # Optimized: Use includes to prevent N+1 + @followers = User.joins(:user_followings) + .where(user_followings: { followed_user_id: @user.id }) + .includes(:avatar_attachment) + .limit(12) + @following = @user.followed_users.includes(:avatar_attachment).limit(12) + + # Optimized: Check follows and blocks efficiently + if user_signed_in? + @is_following = UserFollowing.exists?(user_id: current_user.id, followed_user_id: @user.id) + @is_blocked = UserBlocking.exists?(user_id: current_user.id, blocked_user_id: @user.id) + # Cache followed user IDs to prevent N+1 in view mutual connection checks + @current_user_following_ids = current_user.followed_users.pluck(:id) + else + @is_following = false + @is_blocked = false + @current_user_following_ids = [] + end + end + + def load_user_collections + # Collections user maintains (only public ones visible on profile) + @maintained_collections = @user.page_collections.where(privacy: 'public').order(updated_at: :desc) + + # Collections user is published in + @published_in_collections = @user.published_in_page_collections.limit(20) + end + + def load_user_statistics + # Calculate user statistics + @total_public_pages = @content.values.map(&:count).sum + @total_words = calculate_total_word_count + @join_date = @user.created_at + @last_active = [@user.updated_at, @user.current_sign_in_at].compact.max + + # Activity streak + @activity_streak = calculate_activity_streak + end + + def calculate_total_word_count + total = 0 + @content.each do |content_type, pages| + pages.each do |page| + total += page.cached_word_count if page.respond_to?(:cached_word_count) && page.cached_word_count + end + end + total + end + + def calculate_activity_streak + # Calculate consecutive days of activity (only from content shares since edits are excluded) + activity_dates = @feed.map(&:created_at).map(&:to_date).uniq.sort.reverse + + streak = 0 + current_date = Date.current + + activity_dates.each do |date| + if date == current_date + streak += 1 + current_date -= 1.day + else + break + end + end + + streak + end + + def build_unified_activity_timeline + activities = [] + + # Add content shares + @feed.each do |share| + activities << { + type: 'share', + created_at: share.created_at, + data: share + } + end + + # Add recent edits + @recent_edits.each do |edit| + activities << { + type: 'edit', + created_at: edit.created_at, + data: edit + } + end + + # Add forum posts + @recent_forum_posts.each do |post| + activities << { + type: 'forum_post', + created_at: post.created_at, + data: post + } + end + + # Sort by most recent first + activities.sort_by { |activity| activity[:created_at] }.reverse.first(20) + end + # Get most popular tags for a user's public content def get_popular_public_tags_for_user(user, limit: 10) # Find page tags attached to public content diff --git a/app/controllers/word_count_updates_controller.rb b/app/controllers/word_count_updates_controller.rb new file mode 100644 index 000000000..8f0e65e53 --- /dev/null +++ b/app/controllers/word_count_updates_controller.rb @@ -0,0 +1,65 @@ +class WordCountUpdatesController < ApplicationController + before_action :authenticate_user! + before_action :set_word_count_update, only: [:update, :destroy] + + def index + # Load user's recent word count updates + @updates = current_user.word_count_updates + .order(for_date: :desc, created_at: :desc) + .limit(200) + + # We need to compute deltas for display + # The simplest way is to replicate a bit of the batch_words_written logic + # but specifically per-record to show what they contributed. + # Group by entity + calculate delta + + # 30-Day Activity Chart + user_today = current_user.current_date_in_time_zone + dates = (0..29).map { |days_ago| user_today - days_ago.days } + @word_counts_by_date = WordCountUpdate.batch_words_written_on_dates(current_user, dates) + + @dashboard_daily_activity = dates.map do |date| + [date.strftime('%m/%d'), @word_counts_by_date[date] || 0] + end.reverse + + # Simple form building object + @new_update = current_user.word_count_updates.build(for_date: Date.current) + end + + def create + @new_update = current_user.word_count_updates.build(word_count_update_params) + @new_update.entity_type = 'ManualAdjustment' + + # Generate a unique integer ID to allow multiple adjustments per day without unique constraint violation + @new_update.entity_id = (Time.now.to_f * 1000).to_i % 2_000_000_000 + + if @new_update.save + redirect_to word_count_updates_path, notice: 'Manual word count adjustment added.' + else + redirect_to word_count_updates_path, alert: "Failed to add adjustment: #{@new_update.errors.full_messages.to_sentence}" + end + end + + def update + if @update.update(word_count_update_params) + redirect_to word_count_updates_path, notice: 'Word count log updated.' + else + redirect_to word_count_updates_path, alert: "Failed to update record: #{@update.errors.full_messages.to_sentence}" + end + end + + def destroy + @update.destroy + redirect_to word_count_updates_path, notice: 'Word count log deleted.' + end + + private + + def set_word_count_update + @update = current_user.word_count_updates.find(params[:id]) + end + + def word_count_update_params + params.require(:word_count_update).permit(:for_date, :word_count) + end +end diff --git a/app/controllers/writing_activity_controller.rb b/app/controllers/writing_activity_controller.rb new file mode 100644 index 000000000..0ddd7d946 --- /dev/null +++ b/app/controllers/writing_activity_controller.rb @@ -0,0 +1,201 @@ +class WritingActivityController < ApplicationController + before_action :authenticate_user! + before_action :set_sidenav_expansion + + def index + @page_title = "Writing Activity" + @period = params[:period].presence || '7d' + @period = '7d' unless %w[24h 7d 30d].include?(@period) + @date_range = calculate_date_range + @period_label = period_label + @period_days = @date_range.count + + # Key metrics + @words_written = WordCountUpdate.words_written_in_range(current_user, @date_range) + @daily_word_counts = prepare_daily_chart_data + @active_days = @daily_word_counts.count { |_date, count| count > 0 } + @most_productive_day = find_most_productive_day + @current_streak = calculate_streak + + # Chart data + @words_by_type = prepare_type_breakdown + + # Activity log and top pages + @recent_updates = fetch_recent_updates + @top_growing_pages = fetch_top_growing_pages + end + + private + + def set_sidenav_expansion + @sidenav_expansion = 'writing' + end + + def calculate_date_range + user_today = current_user.current_date_in_time_zone + case @period + when '24h' + user_today..user_today + when '30d' + (user_today - 29.days)..user_today + else # '7d' + (user_today - 6.days)..user_today + end + end + + def period_label + case @period + when '24h' then 'today' + when '30d' then 'the last 30 days' + else 'the last 7 days' + end + end + + def prepare_daily_chart_data + dates = @date_range.to_a + WordCountUpdate.words_written_on_dates(current_user, dates) + end + + def find_most_productive_day + return nil if @daily_word_counts.empty? + + max_entry = @daily_word_counts.max_by { |_date, count| count } + return nil if max_entry.nil? || max_entry[1] == 0 + + { date: max_entry[0], words: max_entry[1] } + end + + def calculate_streak + user_today = current_user.current_date_in_time_zone + streak = 0 + date = user_today + + # Look back up to 365 days to find the streak + 365.times do + words = WordCountUpdate.words_written_on_date(current_user, date) + if words > 0 + streak += 1 + date -= 1.day + else + # If today has no words yet, check if yesterday started a streak + if date == user_today && streak == 0 + date -= 1.day + next + end + break + end + end + + streak + end + + def prepare_type_breakdown + # Get word counts by entity type for the date range + # We need to calculate deltas, not totals + updates_in_range = current_user.word_count_updates + .where(for_date: @date_range) + .where.not(entity_type: 'ManualAdjustment') + .select(:entity_type, :entity_id, :word_count, :for_date) + .order(:entity_type, :entity_id, :for_date) + + # Group by entity type + type_totals = Hash.new(0) + + # For each entity, calculate the delta within the range + updates_in_range.group_by { |u| [u.entity_type, u.entity_id] }.each do |(_type, _id), records| + # Get the last record in range + last_record = records.max_by(&:for_date) + + # Get the first record before the range for this entity + first_before_range = current_user.word_count_updates + .where(entity_type: last_record.entity_type, entity_id: last_record.entity_id) + .where('for_date < ?', @date_range.first) + .order(for_date: :desc) + .first + + prev_count = first_before_range&.word_count || 0 + delta = last_record.word_count - prev_count + type_totals[last_record.entity_type] += delta if delta > 0 + end + + type_totals + end + + def fetch_recent_updates + # Get recent word count updates with their entities and calculate deltas + updates = current_user.word_count_updates + .where(for_date: @date_range) + .where.not(entity_type: 'ManualAdjustment') + .includes(:entity) + .order(for_date: :desc, updated_at: :desc) + + # Calculate delta for each update by comparing to previous record + updates_with_deltas = [] + updates.each do |update| + # Find the previous record for this entity + prev_record = current_user.word_count_updates + .where(entity_type: update.entity_type, entity_id: update.entity_id) + .where('for_date < ?', update.for_date) + .order(for_date: :desc) + .first + + prev_count = prev_record&.word_count || 0 + delta = update.word_count - prev_count + + # Only include if there was actual change + next if delta == 0 + + updates_with_deltas << { + update: update, + entity: update.entity, + delta: delta, + for_date: update.for_date + } + end + + updates_with_deltas.first(50) + end + + def fetch_top_growing_pages + # Calculate which pages grew the most in the date range + updates_in_range = current_user.word_count_updates + .where(for_date: @date_range) + .where.not(entity_type: 'ManualAdjustment') + .select(:entity_type, :entity_id, :word_count, :for_date) + + # Calculate delta for each entity + page_deltas = [] + + updates_in_range.group_by { |u| [u.entity_type, u.entity_id] }.each do |(entity_type, entity_id), records| + last_record = records.max_by(&:for_date) + + # Get the record before the range + first_before_range = current_user.word_count_updates + .where(entity_type: entity_type, entity_id: entity_id) + .where('for_date < ?', @date_range.first) + .order(for_date: :desc) + .first + + prev_count = first_before_range&.word_count || 0 + delta = last_record.word_count - prev_count + + next unless delta > 0 + + page_deltas << { + entity_type: entity_type, + entity_id: entity_id, + words_added: delta + } + end + + # Sort by words added and take top 10 + top_pages = page_deltas.sort_by { |p| -p[:words_added] }.first(10) + + # Load the actual entities + top_pages.each do |page| + page[:entity] = page[:entity_type].constantize.find_by(id: page[:entity_id]) + end + + top_pages.reject { |p| p[:entity].nil? } + end +end diff --git a/app/controllers/writing_goals_controller.rb b/app/controllers/writing_goals_controller.rb new file mode 100644 index 000000000..b7f9f42bd --- /dev/null +++ b/app/controllers/writing_goals_controller.rb @@ -0,0 +1,133 @@ +class WritingGoalsController < ApplicationController + before_action :authenticate_user! + before_action :set_writing_goal, only: [:edit, :update, :destroy, :complete, :activate, :archive] + before_action :set_sidenav_expansion + + def index + @page_title = "Writing Goals" + @active_goals = current_user.writing_goals.current.order(end_date: :asc) + @archived_goals_count = current_user.writing_goals.where('archived = ? OR completed_at IS NOT NULL', true).count + + # Daily stats for header (always show, even without active goals) + # Uses User#daily_word_goal which defaults to 1,000 if no active goals + @max_daily_goal = current_user.daily_word_goal + + # Words written today + @words_today = current_user.words_written_today + + # Progress toward daily goal + @daily_progress_percentage = @max_daily_goal > 0 ? [(@words_today.to_f / @max_daily_goal * 100), 100.0].min : 0 + + # Words remaining today + @words_remaining_today = [@max_daily_goal - @words_today, 0].max + + # Configurable time range for chart (use user's timezone) + user_today = current_user.current_date_in_time_zone + @chart_days = [7, 14, 30, 90].include?(params[:days].to_i) ? params[:days].to_i : 30 + dates = ((user_today - (@chart_days - 1).days)..user_today).to_a + @daily_word_counts_30_days = WordCountUpdate.words_written_on_dates(current_user, dates) + + # Summary statistics + @total_words_period = @daily_word_counts_30_days.values.sum + @days_goal_met = @daily_word_counts_30_days.count { |_, count| count >= @max_daily_goal } + @current_streak = calculate_writing_streak(@daily_word_counts_30_days, @max_daily_goal) + end + + def history + @page_title = "Writing Goals History" + + # Auto-complete any expired goals that weren't manually completed/archived + user_today = current_user.current_date_in_time_zone + current_user.writing_goals + .where(archived: false, completed_at: nil) + .where('end_date < ?', user_today) + .find_each { |goal| goal.update(completed_at: Time.current, active: false) } + + @archived_goals = current_user.writing_goals + .where('archived = ? OR completed_at IS NOT NULL', true) + .order(end_date: :desc) + end + + def new + @page_title = "New Writing Goal" + @writing_goal = current_user.writing_goals.build + + # Use user's timezone for dates + user_today = current_user.current_date_in_time_zone + + # Use query params if provided, otherwise use defaults + @writing_goal.title = params[:title] || "My Writing Goal" + @writing_goal.target_word_count = params[:target_word_count]&.to_i || 50000 + @writing_goal.start_date = user_today + + days = params[:days]&.to_i || 30 + @writing_goal.end_date = user_today + days.days + end + + def create + @writing_goal = current_user.writing_goals.build(writing_goal_params) + + if @writing_goal.save + redirect_to writing_goals_path, notice: 'Writing goal created successfully!' + else + @page_title = "New Writing Goal" + render :new + end + end + + def edit + @page_title = "Edit Writing Goal" + end + + def update + if @writing_goal.update(writing_goal_params) + redirect_to writing_goals_path, notice: 'Writing goal updated successfully!' + else + @page_title = "Edit Writing Goal" + render :edit + end + end + + def destroy + @writing_goal.destroy + redirect_to writing_goals_path, notice: 'Writing goal deleted.' + end + + def complete + @writing_goal.update(active: false, completed_at: Time.current) + redirect_to writing_goals_path, notice: 'Goal marked as complete!' + end + + def activate + @writing_goal.update(active: true, archived: false, completed_at: nil) + redirect_to writing_goals_path, notice: 'Goal activated!' + end + + def archive + @writing_goal.archive! + redirect_to writing_goals_path, notice: 'Goal archived.' + end + + private + + def set_writing_goal + @writing_goal = current_user.writing_goals.find(params[:id]) + end + + def writing_goal_params + params.require(:writing_goal).permit(:title, :target_word_count, :start_date, :end_date) + end + + def set_sidenav_expansion + @sidenav_expansion = 'writing' + end + + def calculate_writing_streak(word_counts, goal) + streak = 0 + word_counts.to_a.reverse.each do |_date, count| + break if count < goal + streak += 1 + end + streak + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cd32a1c70..d04a70915 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -4,6 +4,9 @@ def content_class_from_name(class_name) # If we pass in a class (e.g. Character instead of "Character") by mistake, just return it return class_name if class_name.is_a?(Class) + # Extra whitelisting for some other classes we don't necessarily want in the content_types array + return Folder if class_name == Folder.name + Rails.application.config.content_types_by_name[class_name] end @@ -100,8 +103,8 @@ def combine_and_sort_gallery_images(regular_images, basil_images) # @return [Hash] The best image to use as a preview (pinned if available) def get_preview_image(regular_images, basil_images) # First look for pinned images - pinned_regular = regular_images.find { |img| img.respond_to?(:pinned?) && img.pinned? } - pinned_basil = basil_images.find { |img| img.respond_to?(:pinned?) && img.pinned? } + pinned_regular = regular_images.find(&:pinned?) + pinned_basil = basil_images.find(&:pinned?) # Use the first pinned image found (prioritize regular uploads if both exist) if pinned_regular.present? @@ -126,4 +129,17 @@ def get_preview_image(regular_images, basil_images) # Return the first sorted image, or nil if none available combined.first end + + def unread_inbox_count + return 0 unless user_signed_in? + + @unread_inbox_count ||= begin + Thredded::PrivateTopic + .for_user(current_user) + .unread(current_user) + .count + rescue + 0 + end + end end diff --git a/app/helpers/attributes_helper.rb b/app/helpers/attributes_helper.rb index a659c4f9d..0d56852cd 100644 --- a/app/helpers/attributes_helper.rb +++ b/app/helpers/attributes_helper.rb @@ -1,4 +1,14 @@ module AttributesHelper + # Resolves linked content from the current user's content cache + # Returns the content object if found, nil otherwise (triggers JS lazy loading) + def resolve_link_content(klass, id) + return nil unless user_signed_in? && @current_user_content + + @current_user_content.fetch(klass, []).detect do |page| + page.page_type == klass && page.id == id.to_i + end + end + #todo this might not actually be used anymore def attribute_category_tab(content, category) is_disabled = category.attribute_fields.any? do |field| @@ -13,4 +23,21 @@ def attribute_category_tab(content, category) # todo: revisit logic for is_disabled -- doesn't disable empty tabs content_tag(:li, link, class: "tab #{'disabled' if false}") end + + # Helper method to resolve linkable content types for link fields + def get_linkable_content_types(linkable_types_array) + return [] if linkable_types_array.blank? + + # Get all available content types + all_content_types = Rails.application.config.content_types[:all] + + # Filter to only include the types that are linkable for this field + all_content_types.select do |content_type| + linkable_types_array.include?(content_type.name) + end.compact + rescue => e + # Graceful degradation if there are any issues resolving content types + Rails.logger.warn "Error resolving linkable content types: #{e.message}" + [] + end end diff --git a/app/helpers/devise_helper.rb b/app/helpers/devise_helper.rb index 3efb4c808..ca61f0d23 100644 --- a/app/helpers/devise_helper.rb +++ b/app/helpers/devise_helper.rb @@ -2,4 +2,16 @@ module DeviseHelper def devise_error_messages! resource.errors.full_messages.map { |msg| content_tag(:li, msg + '.') }.join.html_safe end + + def resource_name + :user + end + + def resource + @resource ||= User.new + end + + def devise_mapping + @devise_mapping ||= Devise.mappings[:user] + end end \ No newline at end of file diff --git a/app/helpers/heroicons_helper.rb b/app/helpers/heroicons_helper.rb new file mode 100644 index 000000000..e31a9eed9 --- /dev/null +++ b/app/helpers/heroicons_helper.rb @@ -0,0 +1,9 @@ +module HeroiconsHelper + def chevron_down + ''' + + '''.html_safe + end +end \ No newline at end of file diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb new file mode 100644 index 000000000..0a06b130c --- /dev/null +++ b/app/helpers/search_helper.rb @@ -0,0 +1,5 @@ +module SearchHelper + def search_params + params.permit(:q, :sort, :filter) + end +end \ No newline at end of file diff --git a/app/helpers/styleguide_helper.rb b/app/helpers/styleguide_helper.rb new file mode 100644 index 000000000..d64111f1d --- /dev/null +++ b/app/helpers/styleguide_helper.rb @@ -0,0 +1,2 @@ +module StyleguideHelper +end diff --git a/app/javascript/application.css b/app/javascript/application.css new file mode 100644 index 000000000..98fa65b84 --- /dev/null +++ b/app/javascript/application.css @@ -0,0 +1,3 @@ +@import "tailwindcss/base"; +@import "tailwindcss/utilities"; +@import "tailwindcss/components"; \ No newline at end of file diff --git a/app/javascript/components/DocumentEntitiesSidebar.js b/app/javascript/components/DocumentEntitiesSidebar.js index 700c944c7..e0094f5c7 100644 --- a/app/javascript/components/DocumentEntitiesSidebar.js +++ b/app/javascript/components/DocumentEntitiesSidebar.js @@ -1,5 +1,8 @@ /* - Usage: + DEPRECATED: This component uses MaterializeCSS and appears to be unused. + All document views have been migrated to TailwindCSS. + + Original Usage: <%= react_component("DocumentEntitiesSidebar", { document_id: 3, diff --git a/app/javascript/components/PageCollectionCreationForm.js b/app/javascript/components/PageCollectionCreationForm.js deleted file mode 100644 index 8cf5ca87b..000000000 --- a/app/javascript/components/PageCollectionCreationForm.js +++ /dev/null @@ -1,180 +0,0 @@ -/* - Usage: - <%= react_component("PageCollectionCreationForm", {}) %> -*/ - -import React from "react" -import PropTypes from "prop-types" - -import Stepper from '@material-ui/core/Stepper'; -import Step from '@material-ui/core/Step'; -import StepLabel from '@material-ui/core/StepLabel'; -import StepContent from '@material-ui/core/StepContent'; -import Button from '@material-ui/core/Button'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; - -class PageCollectionCreationForm extends React.Component { - - constructor(props) { - super(props); - - this.state = { - active_step: 0 - } - - this.handleNext = this.handleNext.bind(this); - this.handleBack = this.handleBack.bind(this); - this.handleReset = this.handleReset.bind(this); - } - - handleNext() { - this.setState({ active_step: this.state.active_step + 1 }); - }; - - handleBack() { - this.setState({ active_step: this.state.active_step - 1 }); - }; - - handleReset() { - this.setState({ active_step: 0 }); - }; - - classIcon(class_name) { - return window.ContentTypeData[class_name].icon; - } - - classColor(class_name) { - return window.ContentTypeData[class_name].color; - } - - steps() { - return ['Basic information', 'Acceptable pages', 'Privacy settings']; - } - - getStepContent(step) { - switch (step) { - case 0: - return( -
    - - Let's get started with some basic information. - -
    - - -
    -
    - - -
    -
    - - -
    -
    -

    Header image (optional)

    -
    -
    - Upload - -
    -
    - -
    -
    -
    - Supported file types: .png, .jpg, .jpeg, .gif -
    -
    -

    -
    - ); - case 1: - return ( -
    - - Please check the types of pages you would like to allow in this collection. A small number of page types is recommended. - -
    - {this.props.all_content_types.map(function(type) { - return( -

    - -

    - ); - })} - -
    -
    - ); - case 2: - return ( -
    - - By default, all Collections are private. However, you can choose to make your Collection public at any time, and you can also choose to - accept submissions from other users! - -
    - ); - - default: - return 'Unknown step'; - } - } - - render () { - return ( -
    - - {this.steps().map((label, index) => ( - - {label} - - {this.getStepContent(index)} -
    -
    - - -
    -
    -
    -
    - ))} -
    - {this.state.active_step === this.steps().length && ( - - All steps completed - you're finished! - - - )} -
    - ); - } -} - -PageCollectionCreationForm.propTypes = { - document_id: PropTypes.number -}; - -export default PageCollectionCreationForm; \ No newline at end of file diff --git a/app/javascript/content_edit_sidebar.js b/app/javascript/content_edit_sidebar.js new file mode 100644 index 000000000..fb7bf118d --- /dev/null +++ b/app/javascript/content_edit_sidebar.js @@ -0,0 +1,142 @@ +// Content edit page sidebar functionality +// Handles autosave status display, keyboard shortcuts, and word count + +document.addEventListener('DOMContentLoaded', function() { + // Only initialize if we're on a content edit page with the sidebar + if (!document.getElementById('save-indicator')) return; + + initializeKeyboardShortcuts(); + initializeAutosaveListeners(); + initializeWordCount(); +}); + +// Detect OS and update keyboard shortcut displays +function initializeKeyboardShortcuts() { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 || + navigator.userAgent.toUpperCase().indexOf('MAC') >= 0; + + document.querySelectorAll('.keyboard-shortcut-save').forEach(function(element) { + element.textContent = isMac ? '⌘S' : 'Ctrl+S'; + }); +} + +// Set up autosave status indicator listeners +function initializeAutosaveListeners() { + // Update save status on success + document.addEventListener('autosave:success', function() { + const lastSavedEl = document.getElementById('last-saved'); + const saveIndicator = document.getElementById('save-indicator'); + const saveText = saveIndicator?.parentElement.querySelector('span'); + + if (lastSavedEl) { + lastSavedEl.textContent = new Date().toLocaleTimeString(); + } + if (saveIndicator) { + saveIndicator.className = 'w-2 h-2 bg-green-400 rounded-full mr-2'; + } + if (saveText) { + saveText.textContent = 'Saved'; + saveText.className = 'text-sm text-gray-700 dark:text-gray-300'; + } + }); + + // Show saving status when autosave starts + document.addEventListener('autosave:start', function() { + const saveIndicator = document.getElementById('save-indicator'); + const saveText = saveIndicator?.parentElement.querySelector('span'); + + if (saveIndicator) { + saveIndicator.className = 'w-2 h-2 bg-yellow-400 rounded-full mr-2'; + } + if (saveText) { + saveText.textContent = 'Saving...'; + saveText.className = 'text-sm text-yellow-700 dark:text-yellow-400'; + } + }); + + // Show unsaved changes when user starts typing + document.addEventListener('input', function(e) { + if (e.target.matches('.js-autosave')) { + const saveIndicator = document.getElementById('save-indicator'); + const saveText = saveIndicator?.parentElement.querySelector('span'); + + if (saveIndicator) { + saveIndicator.className = 'w-2 h-2 bg-amber-400 rounded-full mr-2'; + } + if (saveText) { + saveText.textContent = 'Unsaved changes'; + saveText.className = 'text-sm text-amber-700 dark:text-amber-400'; + } + + // Update word count (debounced) + debouncedWordCountUpdate(); + } + }); +} + +// Word count functionality +let wordCountTimeout = null; + +function initializeWordCount() { + updatePageWordCount(); +} + +// Word count calculation - matches server-side WordCountService behavior +// Rules: split on /\, ignore ..., ---, ___, ignore stray punctuation +function countWordsInText(text) { + if (!text || text.length === 0) return 0; + + // Strip HTML tags (matches server's xhtml: 'remove' option) + text = text.replace(/<[^>]*>/g, ' '); + + // Preserve dates like 01/02/2024 by temporarily replacing slashes in them + text = text.replace(/(\d{1,2})\/(\d{1,2})(\/\d{2,4})?/g, function(match) { + return match.replace(/\//g, '-SLASH-'); + }); + // Split on forward slashes + text = text.replace(/\//g, ' '); + // Restore dates + text = text.replace(/-SLASH-/g, '/'); + + // Split on backslashes + text = text.replace(/\\/g, ' '); + + // Remove dotted lines, dashed lines, underscores (standalone) + text = text.replace(/\.{2,}/g, ' '); // ... or .... + text = text.replace(/-{2,}/g, ' '); // -- or --- + text = text.replace(/_{2,}/g, ' '); // __ or ___ + + // Remove stray punctuation (standalone punctuation not part of words) + text = text.replace(/(? 0; }); + return words.length; +} + +// Aggregate word count from all text fields on the page +function calculatePageWordCount() { + let totalWords = 0; + document.querySelectorAll('.js-autosave').forEach(function(field) { + totalWords += countWordsInText(field.value || ''); + }); + return totalWords; +} + +// Update display with number formatting +function updatePageWordCount() { + const wordCountEl = document.getElementById('page-word-count'); + if (wordCountEl) { + const count = calculatePageWordCount(); + wordCountEl.textContent = count.toLocaleString(); + } +} + +// Debounced word count update (500ms, matches document editor) +function debouncedWordCountUpdate() { + clearTimeout(wordCountTimeout); + wordCountTimeout = setTimeout(updatePageWordCount, 500); +} + +// Export for potential use elsewhere +window.updatePageWordCount = updatePageWordCount; diff --git a/app/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js new file mode 100644 index 000000000..73a0db22f --- /dev/null +++ b/app/javascript/controllers/clipboard_controller.js @@ -0,0 +1,61 @@ +import { Controller } from "stimulus" + +export default class extends Controller { + static values = { + text: String + } + + copy(event) { + event.preventDefault() + const text = this.textValue || this.element.dataset.clipboardText + + if (!text) { + this.showNotification('No link to copy', 'error') + return + } + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => { + this.showNotification('Link copied to clipboard!', 'success') + }).catch((err) => { + console.error('Failed to copy: ', err) + this.fallbackCopy(text) + }) + } else { + this.fallbackCopy(text) + } + } + + fallbackCopy(text) { + const textArea = document.createElement("textarea") + textArea.value = text + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + try { + const successful = document.execCommand('copy') + if (successful) { + this.showNotification('Link copied to clipboard!', 'success') + } else { + this.showNotification('Unable to copy link', 'error') + } + } catch (err) { + console.error('Fallback copy failed:', err) + this.showNotification('Copy failed', 'error') + } + + document.body.removeChild(textArea) + } + + showNotification(message, type) { + if (typeof window.showNotification === 'function') { + window.showNotification(message, type) + } else { + // Fallback if global notification isn't available + alert(message) + } + } +} diff --git a/app/javascript/controllers/countdown_controller.js b/app/javascript/controllers/countdown_controller.js new file mode 100644 index 000000000..7c525af23 --- /dev/null +++ b/app/javascript/controllers/countdown_controller.js @@ -0,0 +1,58 @@ +import { Controller } from "stimulus" +import { railsToIana } from "../utils/timezone" + +export default class extends Controller { + static targets = ["display"] + static values = { timezone: String } + + connect() { + this.updateCountdown() + this.interval = setInterval(() => this.updateCountdown(), 1000) + } + + disconnect() { + if (this.interval) { + clearInterval(this.interval) + } + } + + updateCountdown() { + // Convert Rails timezone name to IANA format for JavaScript Intl API + const railsTimezone = this.hasTimezoneValue ? this.timezoneValue : 'UTC' + const timezone = railsToIana(railsTimezone) + + // Get current time in user's timezone + const now = new Date() + const options = { timeZone: timezone, hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false } + + let timeString + try { + timeString = now.toLocaleTimeString('en-US', options) + } catch (e) { + // Fallback to UTC if timezone is invalid + timeString = now.toLocaleTimeString('en-US', { timeZone: 'UTC', hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false }) + } + + const [hours, minutes, seconds] = timeString.split(':').map(Number) + + // Calculate time remaining until midnight in user's timezone + const secondsUntilMidnight = (24 * 60 * 60) - (hours * 3600 + minutes * 60 + seconds) + + if (secondsUntilMidnight <= 0) { + this.displayTarget.textContent = "00:00:00" + return + } + + const remainingHours = Math.floor(secondsUntilMidnight / 3600) + const remainingMinutes = Math.floor((secondsUntilMidnight % 3600) / 60) + const remainingSeconds = secondsUntilMidnight % 60 + + const formatted = [ + remainingHours.toString().padStart(2, '0'), + remainingMinutes.toString().padStart(2, '0'), + remainingSeconds.toString().padStart(2, '0') + ].join(':') + + this.displayTarget.textContent = formatted + } +} diff --git a/app/javascript/controllers/follow_button_controller.js b/app/javascript/controllers/follow_button_controller.js new file mode 100644 index 000000000..b127596e6 --- /dev/null +++ b/app/javascript/controllers/follow_button_controller.js @@ -0,0 +1,81 @@ +import { Controller } from "stimulus" + +export default class extends Controller { + static values = { + userId: String, + following: Boolean + } + + static targets = ["text", "icon"] + + connect() { + // Initialize state from data attribute if needed, + // but values are already set via data-follow-button-following-value + } + + toggle(event) { + event.preventDefault() + + const button = this.element + const isFollowing = this.followingValue + + // Disable button during request + button.disabled = true + button.style.opacity = '0.7' + + // Determine action and endpoint + const action = isFollowing ? 'DELETE' : 'POST' + const endpoint = isFollowing ? `/users/${this.userIdValue}/unfollow` : `/users/${this.userIdValue}/follow` + + fetch(endpoint, { + method: action, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + credentials: 'same-origin' + }) + .then(response => { + if (response.ok) { + // Toggle the follow state + const newFollowingState = !isFollowing + this.followingValue = newFollowingState + + // Update button appearance + if (newFollowingState) { + button.classList.remove('bg-blue-600', 'hover:bg-blue-700', 'text-white') + button.classList.add('bg-gray-100', 'dark:bg-gray-700', 'hover:bg-gray-200', 'dark:hover:bg-gray-600', 'text-gray-700', 'dark:text-gray-200') + this.textTarget.textContent = 'Following' + this.iconTarget.textContent = 'check' + } else { + button.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'hover:bg-gray-200', 'dark:hover:bg-gray-600', 'text-gray-700', 'dark:text-gray-200') + button.classList.add('bg-blue-600', 'hover:bg-blue-700', 'text-white') + this.textTarget.textContent = 'Follow User' + this.iconTarget.textContent = 'person_add' + } + + // Show success notification + this.showNotification(newFollowingState ? 'Now following user!' : 'Unfollowed user', 'success') + } else { + throw new Error('Network response was not ok') + } + }) + .catch(error => { + console.error('Error:', error) + this.showNotification('Failed to update follow status', 'error') + }) + .finally(() => { + // Re-enable button + button.disabled = false + button.style.opacity = '1' + }) + } + + showNotification(message, type) { + if (typeof window.showNotification === 'function') { + window.showNotification(message, type) + } else { + alert(message) + } + } +} diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 000000000..0f39f8edd --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,6 @@ +import { Controller } from 'stimulus'; +export default class extends Controller { + connect() { + console.log("hello from StimulusJS") + } +} \ No newline at end of file diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 000000000..30414235b --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,12 @@ +import { Application } from "stimulus" +import { definitionsFromContext } from "stimulus/webpack-helpers" + +import Dropdown from 'stimulus-dropdown' +import AnimatedNumber from 'stimulus-animated-number' + +const application = Application.start() +application.register('dropdown', Dropdown) +application.register('animated-number', AnimatedNumber) + +const context = require.context(".", true, /\.js$/) +application.load(definitionsFromContext(context)) \ No newline at end of file diff --git a/app/javascript/controllers/mobile_sidebar_controller.js b/app/javascript/controllers/mobile_sidebar_controller.js new file mode 100644 index 000000000..c1ec85c7e --- /dev/null +++ b/app/javascript/controllers/mobile_sidebar_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "stimulus" + +export default class extends Controller { + static targets = ["sidebar"] + + toggle(event) { + event.stopPropagation() + this.sidebarTarget.classList.toggle("hidden") + } + + close(event) { + // Close if clicking outside sidebar and toggle button + if (!this.sidebarTarget.contains(event.target) && + !this.sidebarTarget.classList.contains("hidden")) { + this.sidebarTarget.classList.add("hidden") + } + } +} diff --git a/app/javascript/controllers/timezone_auto_update_controller.js b/app/javascript/controllers/timezone_auto_update_controller.js new file mode 100644 index 000000000..12fb970b8 --- /dev/null +++ b/app/javascript/controllers/timezone_auto_update_controller.js @@ -0,0 +1,61 @@ +import { Controller } from "stimulus" +import { ianaToRails, detectBrowserTimezone } from "../utils/timezone" + +export default class extends Controller { + static values = { + currentTimezone: String, + updatedAt: Number, // Unix timestamp + updateUrl: String + } + + connect() { + this.maybeAutoUpdate() + } + + maybeAutoUpdate() { + // Skip if already attempted this session + if (sessionStorage.getItem('timezone_auto_updated')) return + + // Check if within migration window (before Jan 31, 2026) + const cutoffDate = new Date('2026-01-31T23:59:59Z') + if (new Date() > cutoffDate) return + + // Check if user has default UTC timezone + if (this.currentTimezoneValue !== 'UTC') return + + // Check if user was created/updated before 2026 (before feature launch) + const featureLaunchDate = new Date('2026-01-01T00:00:00Z') + const userUpdatedAt = new Date(this.updatedAtValue * 1000) + if (userUpdatedAt >= featureLaunchDate) return + + // Detect browser timezone and convert to Rails format + const ianaTimezone = detectBrowserTimezone() + const railsTimezone = ianaToRails(ianaTimezone) + + if (!railsTimezone || railsTimezone === 'UTC') return + + // Mark as attempted + sessionStorage.setItem('timezone_auto_updated', 'true') + + // Send update request with Rails timezone name + this.updateTimezone(railsTimezone) + } + + async updateTimezone(timezone) { + try { + const response = await fetch(this.updateUrlValue, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ time_zone: timezone }) + }) + if (response.ok) { + console.log('Timezone auto-updated to:', timezone) + } + } catch (e) { + console.warn('Failed to auto-update timezone:', e) + } + } +} diff --git a/app/javascript/controllers/timezone_detector_controller.js b/app/javascript/controllers/timezone_detector_controller.js new file mode 100644 index 000000000..be248a137 --- /dev/null +++ b/app/javascript/controllers/timezone_detector_controller.js @@ -0,0 +1,58 @@ +import { Controller } from "stimulus" +import { ianaToRails, detectBrowserTimezone } from "../utils/timezone" + +export default class extends Controller { + static targets = ["detected"] + static values = { current: String } + + connect() { + this.detectTimezone() + } + + detectTimezone() { + try { + const detectedTimezone = detectBrowserTimezone() + const railsTimezone = ianaToRails(detectedTimezone) + + if (railsTimezone && railsTimezone !== this.currentValue) { + // Show the friendly Rails timezone name to the user + this.detectedTarget.textContent = railsTimezone + // Store the IANA name as a data attribute for the apply function + this.detectedTarget.dataset.ianaTimezone = detectedTimezone + this.element.classList.remove('hidden') + } + } catch (e) { + // Browser doesn't support Intl API, hide the detection notice + console.warn('Timezone detection not supported:', e) + } + } + + apply(event) { + event.preventDefault() + const railsTimezone = this.detectedTarget.textContent + + // Try multiple ways to find the timezone select element + const selectElement = document.getElementById('user_time_zone') || + document.querySelector('select[name="user[time_zone]"]') + + if (selectElement) { + // Find the option that matches the Rails timezone name + const options = Array.from(selectElement.options) + const matchingOption = options.find(opt => opt.value === railsTimezone) + + if (matchingOption) { + selectElement.value = railsTimezone + // Hide the detection notice after applying + this.element.classList.add('hidden') + + // Trigger change event for any listeners + selectElement.dispatchEvent(new Event('change', { bubbles: true })) + } else { + // Timezone not in the list (rare edge case) + console.warn('Detected timezone not found in options:', railsTimezone) + } + } else { + console.warn('Could not find timezone select element') + } + } +} diff --git a/app/javascript/controllers/toc_filter_controller.js b/app/javascript/controllers/toc_filter_controller.js new file mode 100644 index 000000000..8c5ea72f6 --- /dev/null +++ b/app/javascript/controllers/toc_filter_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "stimulus" + +export default class extends Controller { + static targets = ["page", "allFilter"] + + filter(event) { + const selectedType = event.currentTarget.dataset.pageType + + this.pageTargets.forEach(el => { + el.style.display = el.dataset.pageType === selectedType ? "" : "none" + }) + } + + showAll() { + this.pageTargets.forEach(el => { + el.style.display = "" + }) + } +} diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 53484ee1e..282c67644 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -15,6 +15,16 @@ // const images = require.context('../images', true) // const imagePath = (name) => images(name, true) +import "../application.css"; +import 'controllers' +import '../page_name_loader' +import '../settings' +import '../content_edit_sidebar' + +// Import Rails UJS for remote forms, CSRF tokens, and confirm dialogs +import Rails from '@rails/ujs' +Rails.start() + // Support component names relative to this directory: var componentRequireContext = require.context("components", true); var ReactRailsUJS = require("react_ujs"); diff --git a/app/javascript/packs/template_editor.js b/app/javascript/packs/template_editor.js new file mode 100644 index 000000000..5b541b9d9 --- /dev/null +++ b/app/javascript/packs/template_editor.js @@ -0,0 +1,2124 @@ +// Template Editor JavaScript + +// Field Deletion Component function for Alpine.js (define before DOM ready) +window.fieldDeletionComponent = function() { + return { + showConfirmation: false, + deleting: false, + + deleteField() { + this.deleting = true; + + // Get field information from the current context + const fieldConfigContainer = document.getElementById('field-config-container'); + const fieldForm = fieldConfigContainer.querySelector('form[action*="/attribute_fields/"]'); + + if (!fieldForm) { + console.error('Field form not found'); + showNotification('Unable to delete field - form not found', 'error'); + this.deleting = false; + return; + } + + // Extract field ID from form action URL + const fieldIdMatch = fieldForm.action.match(/\/attribute_fields\/(\d+)/); + if (!fieldIdMatch) { + console.error('Field ID not found in form action'); + showNotification('Unable to delete field - ID not found', 'error'); + this.deleting = false; + return; + } + + const fieldId = fieldIdMatch[1]; + + // Perform deletion via AJAX + fetch(`/plan/attribute_fields/${fieldId}`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Field deleted successfully:', data); + + // Remove the field from the UI + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + if (fieldItem) { + // Update field count in category header before removing + const categoryId = fieldItem.closest('.fields-container').dataset.categoryId; + fieldItem.remove(); + updateCategoryFieldCount(categoryId); + } + + // Update General Settings counts + updateGeneralSettingsCounts(); + + // Close the configuration panel + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine) { + alpine.selectedField = null; + alpine.configuring = false; + } + } + + // Show success notification + if (data.message) { + showNotification(data.message, 'success'); + } else { + showNotification('Field deleted successfully', 'success'); + } + + this.deleting = false; + }) + .catch(error => { + console.error('Failed to delete field:', error); + showNotification('Failed to delete field', 'error'); + this.deleting = false; + }); + } + }; +}; + +// Template Reset Component function for Alpine.js (define before DOM ready) +window.templateResetComponent = function() { + return { + resetOpen: false, + resetConfirm: false, + resetAnalysis: null, + loading: false, + confirmText: '', + + toggleReset() { + this.resetOpen = !this.resetOpen; + if (this.resetOpen && !this.resetAnalysis) { + this.fetchAnalysis(); + } + }, + + fetchAnalysis() { + console.log('Fetching reset analysis...'); + this.loading = true; + const contentType = document.querySelector('.attributes-editor').dataset.contentType; + + fetch(`/plan/${contentType}/template/reset`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + console.log('Analysis data:', data); + this.resetAnalysis = data; + this.loading = false; + }) + .catch(error => { + console.error('Error:', error); + this.loading = false; + if (typeof showNotification === 'function') { + showNotification('Failed to analyze template reset impact', 'error'); + } else { + alert('Failed to analyze template reset impact'); + } + }); + }, + + performReset() { + console.log('Performing reset...'); + this.loading = true; + const contentType = document.querySelector('.attributes-editor').dataset.contentType; + + fetch(`/plan/${contentType}/template/reset?confirm=true`, { + method: 'DELETE', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + if (typeof showNotification === 'function') { + showNotification(data.message, 'success'); + } else { + alert(data.message); + } + setTimeout(() => window.location.reload(), 2000); + } else { + if (typeof showNotification === 'function') { + showNotification(data.error || 'Failed to reset template', 'error'); + } else { + alert(data.error || 'Failed to reset template'); + } + this.loading = false; + } + }) + .catch(error => { + console.error('Error:', error); + if (typeof showNotification === 'function') { + showNotification('Failed to reset template', 'error'); + } else { + alert('Failed to reset template'); + } + this.loading = false; + }); + } + }; +}; + +// Initialize Alpine component data (global function) +window.initTemplateEditor = function() { + return { + selectedCategory: null, + selectedField: null, + configuring: false, + activePanel: window.innerWidth >= 768 ? 'both' : 'template', + + // Initialize reset analysis as empty object to prevent Alpine errors + resetAnalysis: null, + + // Category selection + selectCategory(categoryId) { + this.selectedCategory = categoryId; + this.selectedField = null; + this.configuring = true; + + if (window.innerWidth < 768) { + this.activePanel = 'config'; + } + + // Show loading animation + showConfigLoadingAnimation('category-config-container'); + + // Load category configuration via AJAX + fetch(`/plan/attribute_categories/${categoryId}/edit`) + .then(response => response.text()) + .then(html => { + document.getElementById('category-config-container').innerHTML = html; + // Bind remote form handlers to newly loaded forms + bindRemoteFormsInContainer('category-config-container'); + // Hide loading animation + hideConfigLoadingAnimation('category-config-container'); + }) + .catch(error => { + console.error('Error loading category config:', error); + hideConfigLoadingAnimation('category-config-container'); + showNotification('Failed to load category configuration', 'error'); + }); + }, + + // Field selection + selectField(fieldId) { + this.selectedField = fieldId; + this.selectedCategory = null; + this.configuring = true; + + if (window.innerWidth < 768) { + this.activePanel = 'config'; + } + + // Show loading animation + showConfigLoadingAnimation('field-config-container'); + + // Load field configuration via AJAX + fetch(`/plan/attribute_fields/${fieldId}/edit`) + .then(response => response.text()) + .then(html => { + document.getElementById('field-config-container').innerHTML = html; + // Bind remote form handlers to newly loaded forms + bindRemoteFormsInContainer('field-config-container'); + // Hide loading animation + hideConfigLoadingAnimation('field-config-container'); + }) + .catch(error => { + console.error('Error loading field config:', error); + hideConfigLoadingAnimation('field-config-container'); + showNotification('Failed to load field configuration', 'error'); + }); + } + }; +}; + +document.addEventListener('DOMContentLoaded', function() { + + // Initialize sortable for categories + initSortables(); + + // Handle remote form submissions for dynamically loaded forms + setupRemoteFormHandlers(); + + // Show category form + document.addEventListener('click', function(event) { + if (event.target.closest('[data-action="click->attributes-editor#showAddCategoryForm"]')) { + const form = document.getElementById('add-category-form'); + form.classList.toggle('hidden'); + } + }); + + // Handle select-category event dispatched from category cards + document.addEventListener('select-category', function(event) { + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine.selectCategory) { + alpine.selectCategory(event.detail.id); + } + } + }); + + // Handle select-field event dispatched from field items + document.addEventListener('select-field', function(event) { + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine.selectField) { + alpine.selectField(event.detail.id); + } + } + }); + + // Category suggestions + document.querySelectorAll('.js-show-category-suggestions').forEach(button => { + button.addEventListener('click', function(e) { + e.preventDefault(); + const contentType = document.querySelector('.attributes-editor').dataset.contentType; + const resultContainer = this.closest('div').querySelector('.suggest-categories-container'); + + // Show loading animation in the suggestions container + showSuggestionsLoadingAnimation(resultContainer); + + fetch(`/plan/attribute_categories/suggest?content_type=${contentType}`) + .then(response => response.json()) + .then(data => { + const existingCategories = Array.from(document.querySelectorAll('.category-label')).map(el => el.textContent.trim()); + const newCategories = data.filter(c => !existingCategories.includes(c)); + + if (newCategories.length > 0) { + resultContainer.innerHTML = ''; + newCategories.forEach(category => { + const chip = document.createElement('span'); + chip.className = 'category-suggestion-link px-3 py-1 bg-gray-100 text-sm text-gray-800 rounded-full hover:bg-gray-200 cursor-pointer'; + chip.textContent = category; + chip.addEventListener('click', function() { + document.querySelector('.js-category-input').value = category; + }); + resultContainer.appendChild(chip); + }); + } else { + resultContainer.innerHTML = '

    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 = $(` +
    + ${icon} + ${message} + +
    + `); + + $('body').append(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + notification.fadeOut(300, function() { + $(this).remove(); + }); + }, 5000); +} + +// Make showNotification globally available +window.showNotification = showNotification; + +// Category visibility toggle function +window.toggleCategoryVisibility = function(categoryId, isHidden) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + const button = categoryCard.querySelector('.category-visibility-toggle'); + + fetch(`/plan/attribute_categories/${categoryId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + attribute_category: { + hidden: !isHidden + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success || data.message) { + // Update the UI manually instead of reloading + const newHiddenState = !isHidden; + + // Update button data attribute and title + button.setAttribute('data-hidden', newHiddenState); + button.setAttribute('title', newHiddenState ? 'Hidden category - Click to show' : 'Visible category - Click to hide'); + + // Update the eye icon + const eyeIcon = button.querySelector('svg'); + if (newHiddenState) { + // Show closed eye icon + eyeIcon.innerHTML = ''; + } else { + // Show open eye icon + eyeIcon.innerHTML = ''; + } + + // Update category card styling + if (newHiddenState) { + categoryCard.classList.add('border-gray-300'); + categoryCard.style.borderColor = ''; + categoryCard.querySelector('.category-header').style.backgroundColor = '#f9fafb'; + categoryCard.querySelector('.category-icon i').classList.add('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = ''; + } else { + categoryCard.classList.remove('border-gray-300'); + const contentTypeColor = getComputedStyle(document.documentElement).getPropertyValue('--content-type-color') || '#6366f1'; + categoryCard.style.borderColor = contentTypeColor; + categoryCard.querySelector('.category-header').style.backgroundColor = contentTypeColor + '20'; + categoryCard.querySelector('.category-icon i').classList.remove('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = contentTypeColor; + } + + // Update hidden status text + const statusText = categoryCard.querySelector('.category-label').parentElement.querySelector('p'); + if (newHiddenState) { + if (!statusText.textContent.includes('— Hidden')) { + statusText.innerHTML += '— Hidden'; + } + } else { + statusText.innerHTML = statusText.innerHTML.replace('— Hidden', ''); + } + + showNotification(data.message, 'success'); + } else { + showNotification('Failed to update category visibility', 'error'); + } + }) + .catch(error => { + console.error('Error toggling category visibility:', error); + showNotification('Failed to update category visibility', 'error'); + }); +}; + +// Field visibility toggle function +window.toggleFieldVisibility = function(fieldId, isHidden) { + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + const button = fieldItem.querySelector('.field-visibility-toggle'); + + fetch(`/plan/attribute_fields/${fieldId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + attribute_field: { + hidden: !isHidden + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success || data.message) { + // Update the UI manually instead of reloading + const newHiddenState = !isHidden; + + // Update button data attribute and title + button.setAttribute('data-hidden', newHiddenState); + button.setAttribute('title', newHiddenState ? 'Hidden field - Click to show' : 'Visible field - Click to hide'); + + // Update the eye icon + const eyeIcon = button.querySelector('svg'); + if (newHiddenState) { + // Show closed eye icon + eyeIcon.innerHTML = ''; + } else { + // Show open eye icon + eyeIcon.innerHTML = ''; + } + + // Update field item styling + if (newHiddenState) { + fieldItem.classList.add('bg-gray-50', 'border-gray-200'); + fieldItem.classList.remove('bg-white'); + fieldItem.querySelector('.field-label').classList.add('text-gray-500'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-800'); + } else { + fieldItem.classList.remove('bg-gray-50', 'border-gray-200'); + fieldItem.classList.add('bg-white'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-500'); + fieldItem.querySelector('.field-label').classList.add('text-gray-800'); + } + + // Update hidden status text in field info + const fieldInfo = fieldItem.querySelector('.text-xs.text-gray-500'); + if (newHiddenState) { + if (!fieldInfo.textContent.includes('— Hidden')) { + fieldInfo.innerHTML += '— Hidden'; + } + } else { + fieldInfo.innerHTML = fieldInfo.innerHTML.replace('— Hidden', ''); + } + + showNotification(data.message, 'success'); + } else { + showNotification('Failed to update field visibility', 'error'); + } + }) + .catch(error => { + console.error('Error toggling field visibility:', error); + showNotification('Failed to update field visibility', 'error'); + }); +}; + +// Category icon preview function +window.updateCategoryIconPreview = function(categoryId, iconName) { + // Update the icon preview in the category header + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (categoryCard) { + const iconElement = categoryCard.querySelector('.category-icon i'); + if (iconElement) { + iconElement.textContent = iconName; + } + } + + // Update the form to show the selected icon + const iconPreview = document.getElementById('selected-icon-preview'); + if (iconPreview) { + iconPreview.textContent = iconName; + } +}; + +// Function to show error messages to users (legacy compatibility) +function showErrorMessage(message) { + showNotification(message, 'error'); +} + +// Bind remote form handlers to dynamically loaded forms in a container +function bindRemoteFormsInContainer(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + // Find all forms with remote: true in the container + const remoteForms = container.querySelectorAll('form[data-remote="true"]'); + + remoteForms.forEach(form => { + // Remove any existing event listeners to prevent duplicates + form.removeEventListener('submit', handleRemoteFormSubmit); + + // Add our custom submit handler + form.addEventListener('submit', handleRemoteFormSubmit); + }); +} + +// Handle remote form submission manually +function handleRemoteFormSubmit(event) { + event.preventDefault(); // Prevent default form submission + + const form = event.target; + const formData = new FormData(form); + const submitButton = form.querySelector('input[type="submit"], button[type="submit"]'); + let originalText = ''; + + // Disable submit button to prevent double submission + if (submitButton) { + submitButton.disabled = true; + originalText = submitButton.value || submitButton.textContent; + if (submitButton.tagName === 'INPUT') { + submitButton.value = 'Saving...'; + } else { + submitButton.textContent = 'Saving...'; + } + + // Restore button after 3 seconds as fallback + setTimeout(() => { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + }, 3000); + } + + // Convert FormData to JSON for Rails + const jsonData = {}; + for (let [key, value] of formData.entries()) { + // Skip Rails form helper fields + if (key === 'utf8' || key === '_method' || key === 'authenticity_token') { + continue; + } + + // Handle nested attributes properly - support multiple levels + if (key.includes('[') && key.includes(']')) { + // Parse nested field names like attribute_field[field_options][input_size] + const keyParts = key.split(/[\[\]]+/).filter(part => part !== ''); + + let current = jsonData; + for (let i = 0; i < keyParts.length - 1; i++) { + const part = keyParts[i]; + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + + const finalKey = keyParts[keyParts.length - 1]; + + // Handle checkbox arrays (multiple values with same key) + if (current[finalKey] !== undefined) { + // If key already exists, convert to array or append to existing array + if (Array.isArray(current[finalKey])) { + if (value !== '') { // Skip empty values (Rails hidden field) + current[finalKey].push(value); + } + } else { + current[finalKey] = [current[finalKey], value].filter(v => v !== ''); + } + } else { + // First occurrence of this key - if it's empty, might be a checkbox array with no selections + current[finalKey] = value === '' && key.includes('[]') ? [] : value; + } + } else { + jsonData[key] = value; + } + } + + console.log('Converted form data:', jsonData); + + // Get the actual HTTP method from Rails form + let httpMethod = form.method.toUpperCase(); + + // Check for Rails method override (for PUT/PATCH/DELETE) + const methodInput = form.querySelector('input[name="_method"]'); + if (methodInput) { + httpMethod = methodInput.value.toUpperCase(); + } + + console.log('Submitting form with method:', httpMethod, 'to:', form.action); + + // Submit form via fetch + fetch(form.action, { + method: httpMethod, + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(jsonData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Form submitted successfully:', data); + + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + } + + // Show success notification + if (data.message) { + showNotification(data.message, 'success'); + } + + // Handle category form updates + if (form.action.includes('/attribute_categories/')) { + handleCategoryFormSuccess(form, data); + } + + // Handle field form updates + if (form.action.includes('/attribute_fields/')) { + handleFieldFormSuccess(form, data); + } + }) + .catch(error => { + console.error('Form submission failed:', error); + + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + } + + showNotification('Failed to save changes', 'error'); + }); +} + +// Setup remote form handlers for dynamically loaded forms +function setupRemoteFormHandlers() { + // Handle successful form submissions (backup for Rails UJS if it works) + document.addEventListener('ajax:success', function(event) { + const form = event.target; + if (!form.matches('form[data-remote="true"]')) return; + + const response = event.detail[0]; + console.log('Form submitted successfully via Rails UJS:', response); + + // Show success notification + if (response.message) { + showNotification(response.message, 'success'); + } + + // Handle category form updates + if (form.action.includes('/attribute_categories/')) { + handleCategoryFormSuccess(form, response); + } + + // Handle field form updates + if (form.action.includes('/attribute_fields/')) { + handleFieldFormSuccess(form, response); + } + }); + + // Handle form submission errors (backup for Rails UJS if it works) + document.addEventListener('ajax:error', function(event) { + const form = event.target; + if (!form.matches('form[data-remote="true"]')) return; + + const response = event.detail[0]; + console.error('Form submission failed via Rails UJS:', response); + + let errorMessage = 'Failed to save changes'; + if (response && response.error) { + errorMessage = response.error; + } + + showNotification(errorMessage, 'error'); + }); +} + +// Handle successful category form submission +function handleCategoryFormSuccess(form, response) { + // Check if this is a new category creation (POST to /attribute_categories) + const isNewCategory = form.method.toUpperCase() === 'POST' && form.action.endsWith('/attribute_categories'); + + if (isNewCategory) { + // Handle new category creation + if (response.category) { + addNewCategoryToUI(response.category, response.rendered_html); + + // Hide the add category form and reset it + const addCategoryForm = document.getElementById('add-category-form'); + if (addCategoryForm) { + addCategoryForm.classList.add('hidden'); + form.reset(); + } + } + return; + } + + // Handle existing category updates + const matches = form.action.match(/\/attribute_categories\/(\d+)/); + if (!matches) return; + + const categoryId = matches[1]; + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (!categoryCard) return; + + // Update category display if label changed + if (response.category && response.category.label) { + const labelElement = categoryCard.querySelector('.category-label'); + if (labelElement) { + labelElement.textContent = response.category.label; + } + } + + // Update category icon if changed + if (response.category && response.category.icon) { + const iconElement = categoryCard.querySelector('.category-icon i'); + if (iconElement) { + iconElement.textContent = response.category.icon; + } + } + + // Handle archive/visibility changes + if (response.category && typeof response.category.hidden !== 'undefined') { + const isArchived = response.category.hidden; + updateCategoryArchiveUI(categoryId, isArchived); + updateArchivedItemsCount(); + + // Auto-enable "Show archived items" when archiving from config panel + if (isArchived) { + const alpineElement = document.querySelector('[x-data*="showArchived"]'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine && !alpine.showArchived) { + alpine.showArchived = true; + toggleArchivedItems(true); + } + } + } + + // Reload the configuration panel to show the updated archive state + reloadCategoryConfiguration(categoryId); + + // Update General Settings counts when archive status changes + updateGeneralSettingsCounts(); + } +} + +// Reload category configuration panel to reflect updated state +function reloadCategoryConfiguration(categoryId) { + // Only reload if this category's configuration is currently open + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + + if (alpine && alpine.selectedCategory == categoryId) { + console.log(`Reloading configuration for category ${categoryId}`); + + // Show loading animation + showConfigLoadingAnimation('category-config-container'); + + // Reload category configuration via AJAX + fetch(`/plan/attribute_categories/${categoryId}/edit`) + .then(response => response.text()) + .then(html => { + document.getElementById('category-config-container').innerHTML = html; + // Bind remote form handlers to newly loaded forms + bindRemoteFormsInContainer('category-config-container'); + // Hide loading animation + hideConfigLoadingAnimation('category-config-container'); + }) + .catch(error => { + console.error('Error reloading category config:', error); + hideConfigLoadingAnimation('category-config-container'); + showNotification('Failed to reload category configuration', 'error'); + }); + } + } +} + +// Add new field to the UI using server-rendered HTML +function addNewFieldToUI(field, renderedHtml, form) { + // Find the fields container for the category this field belongs to + const categoryId = field.attribute_category_id; + const fieldsContainer = document.querySelector(`.fields-container[data-category-id="${categoryId}"]`); + + if (!fieldsContainer) { + console.error(`Fields container not found for category ${categoryId}`); + return; + } + + // If we have rendered HTML from the server, use that + if (renderedHtml) { + // Add the new field to the fields container + fieldsContainer.insertAdjacentHTML('beforeend', renderedHtml); + + // Update the category field count in the header + updateCategoryFieldCount(categoryId); + + // Update General Settings counts + updateGeneralSettingsCounts(); + + console.log(`Added new field "${field.label}" to category ${categoryId} using server-rendered HTML`); + return; + } + + // Fallback: If no rendered HTML provided, log error and skip + console.error('No rendered HTML provided for new field. Field not added to UI.'); +} + +// Update category field count in the header +function updateCategoryFieldCount(categoryId) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (!categoryCard) return; + + const fieldsContainer = categoryCard.querySelector(`.fields-container[data-category-id="${categoryId}"]`); + if (!fieldsContainer) return; + + // Count visible fields (excluding archived ones if they're hidden) + const fieldItems = fieldsContainer.querySelectorAll('.field-item'); + const visibleFields = Array.from(fieldItems).filter(field => + field.style.display !== 'none' + ); + + // Update the count in the category header + const fieldCountElement = categoryCard.querySelector('.category-label').parentElement.querySelector('p'); + if (fieldCountElement) { + const count = visibleFields.length; + const fieldText = count === 1 ? 'field' : 'fields'; + + // Update just the count part, preserving any status text (like "— Archived") + const statusMatch = fieldCountElement.innerHTML.match(/]*>.*?<\/span>/); + const statusText = statusMatch ? statusMatch[0] : ''; + fieldCountElement.innerHTML = `${count} ${fieldText}${statusText ? ' ' + statusText : ''}`; + } +} + +// Add new category to the UI using server-rendered HTML +function addNewCategoryToUI(category, renderedHtml) { + const categoriesContainer = document.getElementById('categories-container'); + if (!categoriesContainer) return; + + // If we have rendered HTML from the server, use that instead of generating our own + if (renderedHtml) { + // Find the "Add Category" card and insert the new category before it + const addCategoryCard = categoriesContainer.querySelector('.bg-white.rounded-lg.shadow-sm.border.border-dashed'); + if (addCategoryCard) { + addCategoryCard.insertAdjacentHTML('beforebegin', renderedHtml); + } else { + // Fallback: add to the end of the container + categoriesContainer.insertAdjacentHTML('beforeend', renderedHtml); + } + + console.log(`Added new category "${category.label}" to UI using server-rendered HTML`); + return; + } + + // Fallback: If no rendered HTML provided, log error and skip + console.error('No rendered HTML provided for new category. Category not added to UI.'); +} + +// Handle new field form submission manually +window.submitFieldForm = function(event) { + const form = event.target; + const submitButton = form.querySelector('input[type="submit"], button[type="submit"]'); + + // Disable submit button and show loading state + let originalText = ''; + if (submitButton) { + submitButton.disabled = true; + originalText = submitButton.value || submitButton.textContent; + if (submitButton.tagName === 'INPUT') { + submitButton.value = 'Creating...'; + } else { + submitButton.textContent = 'Creating...'; + } + } + + // Convert form data to JSON + const formData = new FormData(form); + const jsonData = {}; + + for (let [key, value] of formData.entries()) { + // Skip Rails form helper fields + if (key === 'utf8' || key === '_method' || key === 'authenticity_token') { + continue; + } + + // Handle nested attributes properly + if (key.includes('[') && key.includes(']')) { + const keyParts = key.split(/[\[\]]+/).filter(part => part !== ''); + let current = jsonData; + for (let i = 0; i < keyParts.length - 1; i++) { + const part = keyParts[i]; + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + const finalKey = keyParts[keyParts.length - 1]; + + // Handle checkbox arrays (multiple values with same key) + if (current[finalKey] !== undefined) { + // If key already exists, convert to array or append to existing array + if (Array.isArray(current[finalKey])) { + if (value !== '') { // Skip empty values (Rails hidden field) + current[finalKey].push(value); + } + } else { + current[finalKey] = [current[finalKey], value].filter(v => v !== ''); + } + } else { + // First occurrence of this key - if it's empty, might be a checkbox array with no selections + current[finalKey] = value === '' && key.includes('[]') ? [] : value; + } + } else { + jsonData[key] = value; + } + } + + console.log('Submitting new field form:', jsonData); + + // Submit form via fetch + fetch(form.action, { + method: 'POST', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(jsonData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Field created successfully:', data); + + // Handle success + if (data.field && data.html) { + addNewFieldToUI(data.field, data.html, form); + + // Update General Settings counts + updateGeneralSettingsCounts(); + + // Reset the form and close the add field section + form.reset(); + const addingFieldSection = form.closest('[x-data*="addingField"]'); + if (addingFieldSection) { + // Use Alpine.js to close the form + const alpineData = Alpine.$data(addingFieldSection); + if (alpineData) { + alpineData.addingField = false; + } + } + + // Show success notification + if (data.message) { + showNotification(data.message, 'success'); + } else { + showNotification(`Field "${data.field.label}" created successfully`, 'success'); + } + } + }) + .catch(error => { + console.error('Failed to create field:', error); + showNotification('Failed to create field', 'error'); + }) + .finally(() => { + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + } + }); +}; + +// Handle new category form submission manually +window.submitCategoryForm = function(event) { + const form = event.target; + const submitButton = form.querySelector('input[type="submit"], button[type="submit"]'); + + // Disable submit button and show loading state + let originalText = ''; + if (submitButton) { + submitButton.disabled = true; + originalText = submitButton.value || submitButton.textContent; + if (submitButton.tagName === 'INPUT') { + submitButton.value = 'Creating...'; + } else { + submitButton.textContent = 'Creating...'; + } + } + + // Convert form data to JSON + const formData = new FormData(form); + const jsonData = {}; + + for (let [key, value] of formData.entries()) { + // Skip Rails form helper fields + if (key === 'utf8' || key === '_method' || key === 'authenticity_token') { + continue; + } + + // Handle nested attributes properly + if (key.includes('[') && key.includes(']')) { + const keyParts = key.split(/[\[\]]+/).filter(part => part !== ''); + let current = jsonData; + for (let i = 0; i < keyParts.length - 1; i++) { + const part = keyParts[i]; + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + const finalKey = keyParts[keyParts.length - 1]; + + // Handle checkbox arrays (multiple values with same key) + if (current[finalKey] !== undefined) { + // If key already exists, convert to array or append to existing array + if (Array.isArray(current[finalKey])) { + if (value !== '') { // Skip empty values (Rails hidden field) + current[finalKey].push(value); + } + } else { + current[finalKey] = [current[finalKey], value].filter(v => v !== ''); + } + } else { + // First occurrence of this key - if it's empty, might be a checkbox array with no selections + current[finalKey] = value === '' && key.includes('[]') ? [] : value; + } + } else { + jsonData[key] = value; + } + } + + console.log('Submitting new category form:', jsonData); + + // Submit form via fetch + fetch(form.action, { + method: 'POST', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(jsonData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + console.log('Category created successfully:', data); + + // Handle success + if (data.category) { + addNewCategoryToUI(data.category, data.rendered_html); + + // Update General Settings counts + updateGeneralSettingsCounts(); + + // Hide the form and reset it + document.getElementById('add-category-form').classList.add('hidden'); + form.reset(); + + // Show success notification + if (data.message) { + showNotification(data.message, 'success'); + } else { + showNotification(`Category "${data.category.label}" created successfully`, 'success'); + } + } + }) + .catch(error => { + console.error('Failed to create category:', error); + showNotification('Failed to create category', 'error'); + }) + .finally(() => { + // Re-enable submit button + if (submitButton) { + submitButton.disabled = false; + if (submitButton.tagName === 'INPUT') { + submitButton.value = originalText; + } else { + submitButton.textContent = originalText; + } + } + }); +}; + +// Handle successful field form submission +function handleFieldFormSuccess(form, response) { + // Check if this is a new field creation (POST to /attribute_fields) + const isNewField = form.method.toUpperCase() === 'POST' && form.action.endsWith('/attribute_fields'); + + if (isNewField) { + // Handle new field creation + if (response.field && response.html) { + addNewFieldToUI(response.field, response.html, form); + + // Update General Settings counts for new field + updateGeneralSettingsCounts(); + + // Reset the form and close the add field section + form.reset(); + const addingFieldSection = form.closest('[x-data*="addingField"]'); + if (addingFieldSection) { + // Use Alpine.js to close the form + const alpineData = Alpine.$data(addingFieldSection); + if (alpineData) { + alpineData.addingField = false; + } + } + } + return; + } + + // Handle existing field updates + const matches = form.action.match(/\/attribute_fields\/(\d+)/); + if (!matches) return; + + const fieldId = matches[1]; + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + if (!fieldItem) return; + + // For field updates, reload the field item with fresh server-rendered HTML + // This ensures all changes (label, linkable_types, visibility, etc.) are reflected + if (response.field && response.html) { + // Replace the existing field item with the updated one + fieldItem.outerHTML = response.html; + console.log(`Updated field "${response.field.label}" UI with server-rendered HTML`); + return; + } + + // Fallback: Manual updates if no HTML provided (legacy support) + // Update field display if label changed + if (response.field && response.field.label) { + const labelElement = fieldItem.querySelector('.field-label'); + if (labelElement) { + labelElement.textContent = response.field.label; + } + } + + // Handle visibility changes + if (response.field && typeof response.field.hidden !== 'undefined') { + const isHidden = response.field.hidden; + updateFieldVisibilityUI(fieldId, isHidden); + } +} + +// Update category visibility UI elements +function updateCategoryVisibilityUI(categoryId, isHidden) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (!categoryCard) return; + + // Update visibility toggle button + const button = categoryCard.querySelector('.category-visibility-toggle'); + if (button) { + button.setAttribute('data-hidden', isHidden); + button.setAttribute('title', isHidden ? 'Hidden category - Click to show' : 'Visible category - Click to hide'); + + const eyeIcon = button.querySelector('svg'); + if (eyeIcon) { + if (isHidden) { + eyeIcon.innerHTML = ''; + } else { + eyeIcon.innerHTML = ''; + } + } + } + + // Update category card styling + if (isHidden) { + categoryCard.classList.add('border-gray-300'); + categoryCard.style.borderColor = ''; + categoryCard.querySelector('.category-header').style.backgroundColor = '#f9fafb'; + categoryCard.querySelector('.category-icon i').classList.add('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = ''; + } else { + categoryCard.classList.remove('border-gray-300'); + const contentTypeColor = getComputedStyle(document.documentElement).getPropertyValue('--content-type-color') || '#6366f1'; + categoryCard.style.borderColor = contentTypeColor; + categoryCard.querySelector('.category-header').style.backgroundColor = contentTypeColor + '20'; + categoryCard.querySelector('.category-icon i').classList.remove('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = contentTypeColor; + } + + // Update hidden status text + const statusText = categoryCard.querySelector('.category-label').parentElement.querySelector('p'); + if (statusText) { + if (isHidden) { + if (!statusText.textContent.includes('— Hidden')) { + statusText.innerHTML += '— Hidden'; + } + } else { + statusText.innerHTML = statusText.innerHTML.replace('— Hidden', ''); + } + } +} + +// Update field visibility UI elements +function updateFieldVisibilityUI(fieldId, isHidden) { + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + if (!fieldItem) return; + + // Update visibility toggle button + const button = fieldItem.querySelector('.field-visibility-toggle'); + if (button) { + button.setAttribute('data-hidden', isHidden); + button.setAttribute('title', isHidden ? 'Hidden field - Click to show' : 'Visible field - Click to hide'); + + const eyeIcon = button.querySelector('svg'); + if (eyeIcon) { + if (isHidden) { + eyeIcon.innerHTML = ''; + } else { + eyeIcon.innerHTML = ''; + } + } + } + + // Update field item styling + if (isHidden) { + fieldItem.classList.add('bg-gray-50', 'border-gray-200'); + fieldItem.classList.remove('bg-white'); + fieldItem.querySelector('.field-label').classList.add('text-gray-500'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-800'); + } else { + fieldItem.classList.remove('bg-gray-50', 'border-gray-200'); + fieldItem.classList.add('bg-white'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-500'); + fieldItem.querySelector('.field-label').classList.add('text-gray-800'); + } + + // Update hidden status text + const fieldInfo = fieldItem.querySelector('.text-xs.text-gray-500'); + if (fieldInfo) { + if (isHidden) { + if (!fieldInfo.textContent.includes('— Hidden')) { + fieldInfo.innerHTML += '— Hidden'; + } + } else { + fieldInfo.innerHTML = fieldInfo.innerHTML.replace('— Hidden', ''); + } + } +} + +// Update category configuration form position field after drag and drop +function updateCategoryConfigPosition(categoryId, newPosition) { + // Check if the category config form is currently open + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + + // Only update if this category's config form is currently open + if (alpine.selectedCategory == categoryId) { + const categoryConfigContainer = document.getElementById('category-config-container'); + if (categoryConfigContainer) { + const positionInput = categoryConfigContainer.querySelector('input[name="attribute_category[position]"]'); + if (positionInput) { + console.log(`Updating category ${categoryId} position input from ${positionInput.value} to ${newPosition}`); + positionInput.value = newPosition; + } + } + } + } +} + +// Update field configuration form position field after drag and drop +function updateFieldConfigPosition(fieldId, newPosition) { + // Check if the field config form is currently open + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + + // Only update if this field's config form is currently open + if (alpine.selectedField == fieldId) { + const fieldConfigContainer = document.getElementById('field-config-container'); + if (fieldConfigContainer) { + const positionInput = fieldConfigContainer.querySelector('input[name="attribute_field[position]"]'); + if (positionInput) { + console.log(`Updating field ${fieldId} position input from ${positionInput.value} to ${newPosition}`); + positionInput.value = newPosition; + } + } + } + } +} + +// Show loading animation for configuration panels +function showConfigLoadingAnimation(containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + // Create loading bar element + const loadingBar = document.createElement('div'); + loadingBar.className = 'config-loading-bar'; + loadingBar.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `; + + // Add loading styles + const style = document.createElement('style'); + style.textContent = ` + .config-loading-bar { + position: relative; + padding: 1rem; + } + + .loading-bar-container { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background-color: #f3f4f6; + overflow: hidden; + } + + .loading-bar-progress { + height: 100%; + background: linear-gradient(90deg, var(--content-type-color, #6366f1) 0%, rgba(99, 102, 241, 0.6) 50%, var(--content-type-color, #6366f1) 100%); + background-size: 200% 100%; + animation: loading-slide 1.5s ease-in-out infinite; + } + + @keyframes loading-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } + } + + .loading-content { + margin-top: 1rem; + } + `; + + // Add styles to head if not already present + if (!document.querySelector('.config-loading-styles')) { + style.classList.add('config-loading-styles'); + document.head.appendChild(style); + } + + // Replace container content with loading animation + container.innerHTML = ''; + container.appendChild(loadingBar); +} + +// Hide loading animation for configuration panels +function hideConfigLoadingAnimation(containerId) { + // The loading animation will be replaced when the actual content loads + // This function is called after the content is set, so it's mainly for cleanup + // and error handling scenarios + + // Remove any loading-specific styles or elements if needed + const container = document.getElementById(containerId); + if (container) { + const loadingBar = container.querySelector('.config-loading-bar'); + if (loadingBar) { + loadingBar.remove(); + } + } +} + +// Show loading animation for suggestions containers +function showSuggestionsLoadingAnimation(container) { + if (!container) return; + + // Create compact loading indicator for suggestions + const loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'suggestions-loading-indicator'; + loadingIndicator.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    +
    +
    + Loading suggestions... +
    + `; + + // Add suggestion loading styles if not already present + if (!document.querySelector('.suggestions-loading-styles')) { + const style = document.createElement('style'); + style.classList.add('suggestions-loading-styles'); + style.textContent = ` + .suggestions-loading-indicator { + position: relative; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + background-color: #f9fafb; + margin: 0.5rem 0; + padding: 0.75rem; + } + + .suggestions-loading-indicator .loading-bar-container { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background-color: #f3f4f6; + overflow: hidden; + border-radius: 0.375rem 0.375rem 0 0; + } + + .suggestions-loading-indicator .loading-bar-progress { + height: 100%; + background: linear-gradient(90deg, var(--content-type-color, #6366f1) 0%, rgba(99, 102, 241, 0.6) 50%, var(--content-type-color, #6366f1) 100%); + background-size: 200% 100%; + animation: loading-slide 1.2s ease-in-out infinite; + } + + .suggestions-loading-indicator .loading-content { + display: flex; + align-items: center; + justify-content: center; + padding-top: 0.25rem; + } + + .suggestions-loading-indicator .loading-dots { + display: flex; + align-items: center; + margin-right: 0.5rem; + } + + .suggestions-loading-indicator .loading-dots .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #9ca3af; + margin: 0 2px; + animation: loading-dots 1.4s ease-in-out infinite both; + } + + .suggestions-loading-indicator .loading-dots .dot:nth-child(1) { + animation-delay: -0.32s; + } + + .suggestions-loading-indicator .loading-dots .dot:nth-child(2) { + animation-delay: -0.16s; + } + + .suggestions-loading-indicator .loading-text { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; + } + + @keyframes loading-dots { + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } + } + `; + document.head.appendChild(style); + } + + // Replace container content with loading animation + container.innerHTML = ''; + container.appendChild(loadingIndicator); +} + +// Archive/restore functionality for fields +window.toggleFieldArchive = function(fieldId, isArchived) { + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + const button = fieldItem.querySelector('.field-archive-toggle'); + + // Show loading state + const originalIcon = button.innerHTML; + button.innerHTML = '
    '; + button.disabled = true; + + fetch(`/plan/attribute_fields/${fieldId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + attribute_field: { + hidden: !isArchived + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success || data.message) { + const newArchivedState = !isArchived; + updateFieldArchiveUI(fieldId, newArchivedState); + updateArchivedItemsCount(); + updateGeneralSettingsCounts(); + + // Auto-enable "Show archived items" when archiving + if (newArchivedState) { + const alpineElement = document.querySelector('[x-data*="showArchived"]'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine && !alpine.showArchived) { + alpine.showArchived = true; + toggleArchivedItems(true); + } + } + } + + const action = newArchivedState ? 'archived' : 'restored'; + showNotification(`Field ${action} successfully`, 'success'); + } else { + // Restore original state on error + button.innerHTML = originalIcon; + button.disabled = false; + showNotification('Failed to update field archive status', 'error'); + } + }) + .catch(error => { + console.error('Error toggling field archive:', error); + // Restore original state on error + button.innerHTML = originalIcon; + button.disabled = false; + showNotification('Failed to update field archive status', 'error'); + }); +}; + +// Archive/restore functionality for categories +window.toggleCategoryArchive = function(categoryId, isArchived) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + const button = categoryCard.querySelector('.category-archive-toggle'); + + // Show loading state + const originalIcon = button.innerHTML; + button.innerHTML = '
    '; + button.disabled = true; + + fetch(`/plan/attribute_categories/${categoryId}`, { + method: 'PUT', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + attribute_category: { + hidden: !isArchived + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success || data.message) { + const newArchivedState = !isArchived; + + // If archiving a category, close its configuration panel if it's currently open + if (newArchivedState) { + const alpineElement = document.querySelector('.attributes-editor'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine && alpine.selectedCategory == categoryId) { + // Deselect the category and close configuration panel + alpine.selectedCategory = null; + alpine.configuring = false; + console.log(`Closed configuration panel for archived category ${categoryId}`); + } + } + } + + updateCategoryArchiveUI(categoryId, newArchivedState); + updateArchivedItemsCount(); + updateGeneralSettingsCounts(); + + // Auto-enable "Show archived items" when archiving + if (newArchivedState) { + const alpineElement = document.querySelector('[x-data*="showArchived"]'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine && !alpine.showArchived) { + alpine.showArchived = true; + toggleArchivedItems(true); + } + } + } + + const action = newArchivedState ? 'archived' : 'restored'; + showNotification(`Category ${action} successfully`, 'success'); + } else { + // Restore original state on error + button.innerHTML = originalIcon; + button.disabled = false; + showNotification('Failed to update category archive status', 'error'); + } + }) + .catch(error => { + console.error('Error toggling category archive:', error); + // Restore original state on error + button.innerHTML = originalIcon; + button.disabled = false; + showNotification('Failed to update category archive status', 'error'); + }); +}; + +// Toggle show/hide archived items (categories and fields) +window.toggleArchivedItems = function(show) { + const archivedFields = document.querySelectorAll('.field-item[data-archived="true"]'); + const archivedCategories = document.querySelectorAll('.category-card[data-archived="true"]'); + + archivedFields.forEach(field => { + if (show) { + field.style.display = ''; + field.classList.add('archived-field'); + } else { + field.style.display = 'none'; + field.classList.remove('archived-field'); + } + }); + + archivedCategories.forEach(category => { + if (show) { + category.style.display = ''; + category.classList.add('archived-category'); + } else { + category.style.display = 'none'; + category.classList.remove('archived-category'); + } + }); + + // Show first-time user tip if showing archived items for the first time + if (show && !localStorage.getItem('seen_archive_tip')) { + showArchiveTip(); + localStorage.setItem('seen_archive_tip', 'true'); + } +}; + +// Update field archive UI elements +function updateFieldArchiveUI(fieldId, isArchived) { + const fieldItem = document.querySelector(`[data-field-id="${fieldId}"]`); + if (!fieldItem) return; + + // Update data attribute + fieldItem.setAttribute('data-archived', isArchived); + + // Update archive toggle button + const button = fieldItem.querySelector('.field-archive-toggle'); + if (button) { + button.setAttribute('data-archived', isArchived); + button.setAttribute('title', isArchived ? 'Archived field - Click to restore' : 'Active field - Click to archive'); + button.disabled = false; // Re-enable button after successful update + + // Update button content with correct icon + if (isArchived) { + button.innerHTML = 'unarchive'; + } else { + button.innerHTML = 'archive'; + } + } + + // Update field item styling + if (isArchived) { + fieldItem.classList.add('bg-gray-100', 'border-gray-300', 'opacity-60', 'archived-field'); + fieldItem.classList.remove('bg-white'); + fieldItem.querySelector('.field-label').classList.add('text-gray-500'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-800'); + + // Add archive icon to label if not present + const label = fieldItem.querySelector('.field-label'); + if (!label.querySelector('.material-icons')) { + label.innerHTML += 'archive'; + } + } else { + fieldItem.classList.remove('bg-gray-100', 'border-gray-300', 'opacity-60', 'archived-field'); + fieldItem.classList.add('bg-white'); + fieldItem.querySelector('.field-label').classList.remove('text-gray-500'); + fieldItem.querySelector('.field-label').classList.add('text-gray-800'); + + // Remove archive icon from label + const archiveIcon = fieldItem.querySelector('.field-label .material-icons'); + if (archiveIcon) { + archiveIcon.remove(); + } + } + + // Update archive status text + const fieldInfo = fieldItem.querySelector('.text-xs.text-gray-500'); + if (fieldInfo) { + // Remove existing status spans + fieldInfo.querySelectorAll('span').forEach(span => { + if (span.textContent.includes('Hidden') || span.textContent.includes('Archived')) { + span.remove(); + } + }); + + // Add archived status if needed + if (isArchived) { + const archivedSpan = document.createElement('span'); + archivedSpan.className = 'ml-1.5 text-amber-600'; + archivedSpan.textContent = '— Archived'; + fieldInfo.appendChild(archivedSpan); + } + } + + // Hide archived field if show archived is off + const showArchivedToggle = document.querySelector('input[x-model="showArchived"]'); + if (showArchivedToggle && !showArchivedToggle.checked && isArchived) { + fieldItem.style.display = 'none'; + } else if (!isArchived) { + fieldItem.style.display = ''; + } +} + +// Update category archive UI elements +function updateCategoryArchiveUI(categoryId, isArchived) { + const categoryCard = document.querySelector(`[data-category-id="${categoryId}"]`); + if (!categoryCard) return; + + // Update data attribute + categoryCard.setAttribute('data-archived', isArchived); + + // Update archive button data attribute and title + const archiveButton = categoryCard.querySelector('.category-archive-toggle'); + if (archiveButton) { + archiveButton.setAttribute('data-archived', isArchived); + archiveButton.setAttribute('title', isArchived ? 'Archived category - Click to restore' : 'Active category - Click to archive'); + archiveButton.disabled = false; // Re-enable button after successful update + + // Update the button icon + if (isArchived) { + archiveButton.innerHTML = 'unarchive'; + } else { + archiveButton.innerHTML = 'archive'; + } + } + + // Update category styling and archive icon + if (isArchived) { + categoryCard.classList.add('border-gray-300', 'opacity-60', 'archived-category'); + categoryCard.style.borderColor = ''; + categoryCard.querySelector('.category-header').style.backgroundColor = '#f3f4f6'; + categoryCard.querySelector('.category-icon i').classList.add('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = ''; + + // Add archive icon to label if not present + const label = categoryCard.querySelector('.category-label'); + if (!label.querySelector('.material-icons')) { + label.innerHTML += 'archive'; + } + } else { + categoryCard.classList.remove('border-gray-300', 'opacity-60', 'archived-category'); + const contentTypeColor = getComputedStyle(document.documentElement).getPropertyValue('--content-type-color') || '#6366f1'; + categoryCard.style.borderColor = contentTypeColor; + categoryCard.querySelector('.category-header').style.backgroundColor = contentTypeColor + '20'; + categoryCard.querySelector('.category-icon i').classList.remove('text-gray-400'); + categoryCard.querySelector('.category-icon i').style.color = contentTypeColor; + + // Remove archive icon from label + const archiveIcon = categoryCard.querySelector('.category-label .material-icons'); + if (archiveIcon) { + archiveIcon.remove(); + } + } + + // Update archived status in category details + const statusText = categoryCard.querySelector('.category-label').parentElement.querySelector('p'); + if (statusText) { + // Remove existing status spans + statusText.querySelectorAll('span').forEach(span => { + if (span.textContent.includes('Hidden') || span.textContent.includes('Archived')) { + span.remove(); + } + }); + + // Add archived status if needed + if (isArchived) { + const archivedSpan = document.createElement('span'); + archivedSpan.className = 'text-amber-600 ml-2'; + archivedSpan.textContent = '— Archived'; + statusText.appendChild(archivedSpan); + } + } + + // Hide archived category if show archived is off + const showArchivedToggle = document.querySelector('input[x-model="showArchived"]'); + if (showArchivedToggle && !showArchivedToggle.checked && isArchived) { + categoryCard.style.display = 'none'; + } else if (!isArchived) { + categoryCard.style.display = ''; + } +} + +// Update archived items count in the settings panel +function updateArchivedItemsCount() { + const archivedFields = document.querySelectorAll('.field-item[data-archived="true"]'); + const archivedCategories = document.querySelectorAll('.category-card[data-archived="true"]'); + const totalArchived = archivedFields.length + archivedCategories.length; + + // Update Alpine.js data for count display + const alpineElement = document.querySelector('[x-data*="showArchived"]'); + if (alpineElement && alpineElement._x_dataStack) { + const alpine = alpineElement._x_dataStack[0]; + if (alpine && typeof alpine.archivedItemsCount !== 'undefined') { + alpine.archivedItemsCount = totalArchived; + } + } +} + +// Update General Settings counts for categories and fields +function updateGeneralSettingsCounts() { + // Count total categories (including archived ones) + const totalCategories = document.querySelectorAll('.category-card').length; + + // Count total fields across all categories (including archived ones) + const totalFields = document.querySelectorAll('.field-item').length; + + // Update the categories count in General Settings + const categoriesCountElement = document.querySelector('.general-settings-categories-count'); + if (categoriesCountElement) { + categoriesCountElement.textContent = totalCategories; + } + + // Update the total fields count in General Settings + const fieldsCountElement = document.querySelector('.general-settings-fields-count'); + if (fieldsCountElement) { + fieldsCountElement.textContent = totalFields; + } + + console.log(`Updated General Settings counts: ${totalCategories} categories, ${totalFields} fields`); +} + +// Make functions globally available +window.updateArchivedItemsCount = updateArchivedItemsCount; +window.updateGeneralSettingsCounts = updateGeneralSettingsCounts; +// Legacy function name for backwards compatibility +window.updateArchivedFieldsCount = updateArchivedItemsCount; + +// Select All / Select None functions for link field configuration +window.selectAllLinkableTypes = function() { + const checkboxes = document.querySelectorAll('input[name="attribute_field[field_options][linkable_types][]"]:not([value=""])'); + checkboxes.forEach(checkbox => { + checkbox.checked = true; + }); +}; + +window.selectNoneLinkableTypes = function() { + const checkboxes = document.querySelectorAll('input[name="attribute_field[field_options][linkable_types][]"]:not([value=""])'); + checkboxes.forEach(checkbox => { + checkbox.checked = false; + }); +}; + +// Show first-time archive tip +function showArchiveTip() { + const tip = document.createElement('div'); + tip.className = 'archive-tip-popup fixed bottom-4 right-4 bg-blue-600 text-white p-4 rounded-lg shadow-lg max-w-sm z-50'; + tip.innerHTML = ` +
    + lightbulb_outline +
    +
    Archive System
    +
    + Archived categories and fields are greyed out to show where they would restore to while keeping them out of your active workspace. +
    + +
    +
    + `; + + document.body.appendChild(tip); + + // Auto-remove tip after 8 seconds + setTimeout(() => { + if (tip.parentElement) { + tip.remove(); + } + }, 8000); +} + +// Initialize archived items count on page load +document.addEventListener('DOMContentLoaded', function() { + updateArchivedItemsCount(); +}); + +// Detect screen size changes to adjust UI +window.addEventListener('resize', function() { + const alpine = Alpine.getRoot(document.querySelector('.attributes-editor')); + if (alpine && alpine.$data) { + if (window.innerWidth >= 768) { + alpine.$data.activePanel = 'both'; + } else if (alpine.$data.configuring) { + alpine.$data.activePanel = 'config'; + } else { + alpine.$data.activePanel = 'template'; + } + } +}); \ No newline at end of file diff --git a/app/javascript/page_name_loader.js b/app/javascript/page_name_loader.js new file mode 100644 index 000000000..1fc0bbec0 --- /dev/null +++ b/app/javascript/page_name_loader.js @@ -0,0 +1,84 @@ +/** + * Page Name Loader + * + * This script handles loading page names for elements with the js-load-page-name class. + * It fetches page names from the API and updates the corresponding elements. + */ + +document.addEventListener('DOMContentLoaded', function() { + // Load page names for elements with the js-load-page-name class + function loadPageNames() { + document.querySelectorAll('.js-load-page-name').forEach(function(element) { + const pageType = element.getAttribute('data-klass'); + const pageId = element.getAttribute('data-id'); + const nameContainer = element.querySelector('.name-container'); + + if (!pageType || !pageId || !nameContainer) return; + + // Check if we've already loaded this name + if (nameContainer.getAttribute('data-loaded') === 'true') return; + + // Mark as loading + nameContainer.setAttribute('data-loading', 'true'); + + // Fetch the page name from the API + fetch(`/api/v1/page_name?type=${encodeURIComponent(pageType)}&id=${encodeURIComponent(pageId)}`) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + if (data && data.name) { + nameContainer.textContent = data.name; + } else { + nameContainer.textContent = `Unnamed ${pageType}`; + } + nameContainer.setAttribute('data-loaded', 'true'); + nameContainer.removeAttribute('data-loading'); + }) + .catch(error => { + console.error('Error loading page name:', error); + nameContainer.textContent = `${pageType} #${pageId}`; + nameContainer.setAttribute('data-loaded', 'true'); + nameContainer.removeAttribute('data-loading'); + }); + }); + } + + // Load page names on page load + loadPageNames(); + + // Also load page names when new content is added to the DOM + // This is useful for dynamically loaded content + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.addedNodes && mutation.addedNodes.length > 0) { + // Check if any of the added nodes have the js-load-page-name class + // or contain elements with that class + for (let i = 0; i < mutation.addedNodes.length; i++) { + const node = mutation.addedNodes[i]; + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.classList && node.classList.contains('js-load-page-name')) { + loadPageNames(); + break; + } else if (node.querySelectorAll) { + const hasLoadableElements = node.querySelectorAll('.js-load-page-name').length > 0; + if (hasLoadableElements) { + loadPageNames(); + break; + } + } + } + } + } + }); + }); + + // Observe the entire document for changes + observer.observe(document.body, { + childList: true, + subtree: true + }); +}); \ No newline at end of file diff --git a/app/javascript/settings.js b/app/javascript/settings.js new file mode 100644 index 000000000..5fd4860c2 --- /dev/null +++ b/app/javascript/settings.js @@ -0,0 +1,387 @@ +// Settings page JavaScript functionality +document.addEventListener('DOMContentLoaded', function() { + // Toggle switches + initializeToggleSwitches(); + + // Form validation + initializeFormValidation(); + + // Password strength meter + initializePasswordStrength(); + + // Toast notifications + initializeToasts(); + + // Mobile navigation + initializeMobileNav(); + + // Tooltips are handled via CSS-only (see application.css) + // Use classes: tooltip-left, tooltip-right, tooltip-top, tooltip-bottom + // With attribute: data-tooltip="Your tooltip text" +}); + +// Initialize toggle switches to replace checkboxes +function initializeToggleSwitches() { + document.querySelectorAll('.toggle-switch').forEach(function(toggle) { + const checkbox = toggle.querySelector('input[type="checkbox"]'); + + // Skip toggle switches that don't have checkboxes (e.g., dropdowns styled similarly) + if (!checkbox) return; + + toggle.addEventListener('click', function(e) { + if (e.target.tagName !== 'INPUT') { + checkbox.checked = !checkbox.checked; + + // Trigger change event for any listeners + const event = new Event('change', { bubbles: true }); + checkbox.dispatchEvent(event); + + // Update toggle appearance + updateToggleState(checkbox); + } + }); + + // Set initial state + updateToggleState(checkbox); + + // Listen for changes + checkbox.addEventListener('change', function() { + updateToggleState(this); + }); + }); +} + +// Update toggle switch appearance based on checkbox state +function updateToggleState(checkbox) { + const toggle = checkbox.closest('.toggle-switch'); + const toggleButton = toggle.querySelector('.toggle-dot'); + + if (checkbox.checked) { + toggle.classList.add('bg-notebook-blue'); + toggle.classList.remove('bg-gray-200'); + toggleButton.classList.add('translate-x-5'); + toggleButton.classList.remove('translate-x-0'); + } else { + toggle.classList.remove('bg-notebook-blue'); + toggle.classList.add('bg-gray-200'); + toggleButton.classList.remove('translate-x-5'); + toggleButton.classList.add('translate-x-0'); + } +} + +// Initialize form validation +function initializeFormValidation() { + // Username validation + const usernameField = document.getElementById('user_username'); + if (usernameField) { + usernameField.addEventListener('input', function() { + validateUsername(this); + }); + + // Initial validation + if (usernameField.value) { + validateUsername(usernameField); + } + } + + // Email validation + const emailField = document.getElementById('user_email'); + if (emailField) { + emailField.addEventListener('input', function() { + validateEmail(this); + }); + + // Initial validation + if (emailField.value) { + validateEmail(emailField); + } + } + + // Character counters + document.querySelectorAll('[data-max-length]').forEach(function(element) { + const maxLength = element.getAttribute('data-max-length'); + const counter = document.createElement('div'); + counter.className = 'text-xs text-right text-gray-500 mt-1'; + counter.innerHTML = `${element.value.length}/${maxLength}`; + element.parentNode.appendChild(counter); + + element.addEventListener('input', function() { + const currentLength = this.value.length; + const counterElement = this.parentNode.querySelector('.current-length'); + counterElement.textContent = currentLength; + + if (currentLength > maxLength) { + counterElement.classList.add('text-red-500'); + } else { + counterElement.classList.remove('text-red-500'); + } + }); + }); +} + +// Validate username +function validateUsername(field) { + const username = field.value; + const feedbackElement = field.parentNode.querySelector('.validation-feedback') || createFeedbackElement(field); + + // Clear previous validation classes + field.classList.remove('border-red-300', 'border-green-300', 'focus:border-red-300', 'focus:border-green-300'); + + if (!feedbackElement) return; // Exit if no feedback container available + + if (username.length === 0) { + feedbackElement.textContent = ''; + feedbackElement.className = 'validation-feedback h-5 text-xs mt-1'; + return; + } + + // Simple validation - can be expanded + const validUsernameRegex = /^[a-zA-Z0-9_\-$+!*]{1,40}$/; + + if (validUsernameRegex.test(username)) { + field.classList.add('border-green-300', 'focus:border-green-300'); + feedbackElement.textContent = 'Username is valid'; + feedbackElement.className = 'validation-feedback h-5 text-xs text-green-600 mt-1'; + } else { + field.classList.add('border-red-300', 'focus:border-red-300'); + feedbackElement.textContent = 'Username can only contain letters, numbers, and - _ $ + ! *'; + feedbackElement.className = 'validation-feedback h-5 text-xs text-red-600 mt-1'; + } +} + +// Validate email +function validateEmail(field) { + const email = field.value; + const feedbackElement = field.parentNode.querySelector('.validation-feedback') || createFeedbackElement(field); + + // Clear previous validation classes + field.classList.remove('border-red-300', 'border-green-300', 'focus:border-red-300', 'focus:border-green-300'); + + if (!feedbackElement) return; // Exit if no feedback container available + + if (email.length === 0) { + feedbackElement.textContent = ''; + feedbackElement.className = 'validation-feedback h-5 text-xs mt-1'; + return; + } + + // Simple email validation + const validEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (validEmailRegex.test(email)) { + // Valid email: just clear error states, no success message + field.classList.remove('border-red-300', 'focus:border-red-300'); + // Optional: Add neutral focus border if needed, but usually default is fine or handled by CSS + feedbackElement.textContent = ''; + feedbackElement.className = 'validation-feedback h-5 text-xs mt-1'; + } else { + // Invalid email + field.classList.add('border-red-300', 'focus:border-red-300'); + feedbackElement.textContent = 'Please enter a valid email address'; + // Improved styling: slightly larger text, maybe a background or just distinct color + // Using a "badge" style or just cleaner text + feedbackElement.className = 'validation-feedback text-xs text-red-600 mt-1 bg-red-50 px-2 py-1 rounded border border-red-100 inline-block'; + } +} + +// Create or find feedback element for validation +function createFeedbackElement(field) { + // First, try to find an existing pre-allocated feedback container + const existingFeedback = field.parentNode.querySelector('.validation-feedback'); + if (existingFeedback) { + return existingFeedback; + } + + // If no pre-allocated container exists, don't create one dynamically + // (this prevents layout shifts - containers should be pre-allocated in the HTML) + return null; +} + +// Initialize password strength meter +function initializePasswordStrength() { + const passwordField = document.getElementById('user_password'); + if (!passwordField) return; + + // Only show password strength on sign-up and password change pages + // Check if we're on a sign-in page by looking for specific elements + const isSignInPage = document.querySelector('form[action*="/users/sign_in"]') || + document.querySelector('input[value="Sign In"]'); + + if (isSignInPage) { + // Don't show password strength on sign-in page + return; + } + + // Try to find a pre-allocated strength meter container + let strengthMeter = document.querySelector('.password-strength'); + + // Ensure pre-allocated containers start with correct visibility state + if (strengthMeter && !strengthMeter.classList.contains('invisible')) { + strengthMeter.classList.add('invisible'); + } + + // If no pre-allocated container exists, create one dynamically (for backwards compatibility) + if (!strengthMeter) { + strengthMeter = document.createElement('div'); + strengthMeter.className = 'password-strength mt-2 h-8 invisible'; + strengthMeter.innerHTML = ` +
    +
    +
    +
    +
    +
    +

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

    Attributes

    +
    + +
    +

    Attributes

    +

    + Overview of custom attribute fields created by users +

    -
    -
    -
    -

    Attributes per user

    - <%= column_chart User.joins(:attribute_fields).group(:user_id).count().group_by { |n| n.last }.each_with_object({}) { |(content_count, ids), h| h[content_count] = ids.count } %> + +
    +
    +
    +
    + + + +
    +
    +

    Total Attributes

    +

    <%= number_with_delimiter @total_attributes %>

    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    Users with Attributes

    +

    <%= number_with_delimiter @total_users %>

    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    Avg per User

    +

    <%= @avg_per_user %>

    +
    +
    +
    - <%# TODO: attributes per each content type %> -
    -

    Attribute privacy

    - <%= pie_chart AttributeField.where.not(privacy: "").group(:privacy).count() %> + + +
    +
    +

    Attributes per User

    + <%= column_chart User.joins(:attribute_fields).group(:user_id).count().group_by { |n| n.last }.each_with_object({}) { |(content_count, ids), h| h[content_count] = ids.count } %> +
    + +
    +

    Attribute Privacy

    + <%= pie_chart AttributeField.where.not(privacy: "").group(:privacy).count() %> +
    +
    + + +
    +

    Attributes by Content Type

    + <% if @by_content_type.any? %> + <%= bar_chart @by_content_type.sort_by { |k, v| -v } %> + <% else %> +

    No content type data available

    + <% end %>
    diff --git a/app/views/admin/churn.html.erb b/app/views/admin/churn.html.erb index be04d9484..7f2fb0293 100644 --- a/app/views/admin/churn.html.erb +++ b/app/views/admin/churn.html.erb @@ -1,53 +1,123 @@ -
    New subscriptions per month
    -<%= - line_chart [ - { name: "Early Adopters", data: Subscription.where(billing_plan_id: 3).group_by_month(:start_date) }, - { name: "Monthly", data: Subscription.where(billing_plan_id: 4).group_by_month(:start_date) }, - { name: "Tri-monthly", data: Subscription.where(billing_plan_id: 5).group_by_month(:start_date) }, - { name: "Yearly", data: Subscription.where(billing_plan_id: 6).group_by_month(:start_date) }, - ] -%> - -
    Cancelled subscriptions per month
    -<%= - line_chart [ - { name: "Early Adopters", data: Subscription.where(billing_plan_id: 3).where('end_date < ?', Date.current).group_by_month(:end_date) }, - { name: "Monthly", data: Subscription.where(billing_plan_id: 4).where('end_date < ?', Date.current).group_by_month(:end_date) }, - { name: "Tri-monthly", data: Subscription.where(billing_plan_id: 5).where('end_date < ?', Date.current).group_by_month(:end_date) }, - { name: "Yearly", data: Subscription.where(billing_plan_id: 6).where('end_date < ?', Date.current).group_by_month(:end_date) }, - ] -%> - -
    -
    Per plan
    -
    Early adopters
    -<%= - line_chart [ - { name: "Started", data: Subscription.where(billing_plan_id: 3).group_by_month(:start_date) }, - { name: "Cancelled", data: Subscription.where(billing_plan_id: 3).where('end_date < ?', Date.current).group_by_month(:end_date) } - ] -%> - -
    Monthly
    -<%= - line_chart [ - { name: "Started", data: Subscription.where(billing_plan_id: 4).group_by_month(:start_date) }, - { name: "Cancelled", data: Subscription.where(billing_plan_id: 4).where('end_date < ?', Date.current).group_by_month(:end_date) } - ] -%> - -
    Tri-monthly
    -<%= - line_chart [ - { name: "Started", data: Subscription.where(billing_plan_id: 5).group_by_month(:start_date) }, - { name: "Cancelled", data: Subscription.where(billing_plan_id: 5).where('end_date < ?', Date.current).group_by_month(:end_date) } - ] -%> - -
    Yearly
    -<%= - line_chart [ - { name: "Started", data: Subscription.where(billing_plan_id: 6).group_by_month(:start_date) }, - { name: "Cancelled", data: Subscription.where(billing_plan_id: 6).where('end_date < ?', Date.current).group_by_month(:end_date) } - ] -%> \ No newline at end of file +
    + +
    +
    +

    Subscription Churn

    +

    + New and cancelled subscriptions over time +

    +
    +
    + <%= form_tag admin_churn_path, method: :get, class: "flex items-center" do %> + <%= select_tag :timespan, + options_for_select([ + ["All Time", ""], + ["Last 30 Days", "30"], + ["Last 90 Days", "90"], + ["Last Year", "365"] + ], @timespan), + class: "rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 text-sm", + onchange: "this.form.submit()" + %> + <% end %> +
    +
    + + <% + # Build base scope with optional date filter + base_scope = @start_date ? Subscription.where('start_date >= ?', @start_date) : Subscription.all + cancelled_scope = @start_date ? Subscription.where('end_date >= ? AND end_date < ?', @start_date, Date.current) : Subscription.where('end_date < ?', Date.current) + %> + + +
    + +
    +
    +

    New subscriptions per month

    + <%= + line_chart [ + { name: "Early Adopters", data: base_scope.where(billing_plan_id: 3).group_by_month(:start_date) }, + { name: "Monthly", data: base_scope.where(billing_plan_id: 4).group_by_month(:start_date) }, + { name: "Tri-monthly", data: base_scope.where(billing_plan_id: 5).group_by_month(:start_date) }, + { name: "Yearly", data: base_scope.where(billing_plan_id: 6).group_by_month(:start_date) }, + ] + %> +
    +
    + + +
    +
    +

    Cancelled subscriptions per month

    + <%= + line_chart [ + { name: "Early Adopters", data: cancelled_scope.where(billing_plan_id: 3).group_by_month(:end_date) }, + { name: "Monthly", data: cancelled_scope.where(billing_plan_id: 4).group_by_month(:end_date) }, + { name: "Tri-monthly", data: cancelled_scope.where(billing_plan_id: 5).group_by_month(:end_date) }, + { name: "Yearly", data: cancelled_scope.where(billing_plan_id: 6).group_by_month(:end_date) }, + ] + %> +
    +
    +
    + + +
    +

    Per Plan Breakdown

    + +
    + +
    +
    +

    Early Adopters

    + <%= + line_chart [ + { name: "Started", data: base_scope.where(billing_plan_id: 3).group_by_month(:start_date) }, + { name: "Cancelled", data: cancelled_scope.where(billing_plan_id: 3).group_by_month(:end_date) } + ] + %> +
    +
    + + +
    +
    +

    Monthly

    + <%= + line_chart [ + { name: "Started", data: base_scope.where(billing_plan_id: 4).group_by_month(:start_date) }, + { name: "Cancelled", data: cancelled_scope.where(billing_plan_id: 4).group_by_month(:end_date) } + ] + %> +
    +
    + + +
    +
    +

    Tri-monthly

    + <%= + line_chart [ + { name: "Started", data: base_scope.where(billing_plan_id: 5).group_by_month(:start_date) }, + { name: "Cancelled", data: cancelled_scope.where(billing_plan_id: 5).group_by_month(:end_date) } + ] + %> +
    +
    + + +
    +
    +

    Yearly

    + <%= + line_chart [ + { name: "Started", data: base_scope.where(billing_plan_id: 6).group_by_month(:start_date) }, + { name: "Cancelled", data: cancelled_scope.where(billing_plan_id: 6).group_by_month(:end_date) } + ] + %> +
    +
    +
    +
    +
    diff --git a/app/views/admin/dashboard.html.erb b/app/views/admin/dashboard.html.erb index e393baeec..af4ecd1db 100644 --- a/app/views/admin/dashboard.html.erb +++ b/app/views/admin/dashboard.html.erb @@ -1,97 +1,113 @@ -
    -
    -
    -
    -
    - 90-day signups - +
    + +
    +

    Analytics Dashboard

    +
    + <% [1, 7, 30, 90].each do |days| %> + <%= link_to admin_dashboard_path(days: days), + class: "px-4 py-2 rounded-lg text-sm font-medium #{@days == days ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'}" do %> + <%= days %>d + <% end %> + <% end %> +
    +
    + + +
    + +
    +
    +
    +

    <%= @days %>-day signups

    + <%= number_with_delimiter User.last.id %> total users
    - <%= area_chart @reports.limit(90).pluck(:day, :user_signups), colors: ['#2196F3'] %> + <%= area_chart @reports.limit(@days).pluck(:day, :user_signups), colors: ['#2196F3'] %>
    -
    -
    -
    -
    -
    - 90-day monthly subscriptions - + + +
    +
    +
    +

    <%= @days %>-day monthly subscriptions

    + <%= number_with_delimiter User.where(selected_billing_plan_id: 4).count %> current total
    - <%= + <%= area_chart [ - { name: 'Started subscriptions', data: @reports.limit(90).pluck(:day, :new_monthly_subscriptions) }, - { name: 'Ended subscriptions', data: @reports.limit(90).pluck(:day, :ended_monthly_subscriptions) } + { name: 'Started subscriptions', data: @reports.limit(@days).pluck(:day, :new_monthly_subscriptions) }, + { name: 'Ended subscriptions', data: @reports.limit(@days).pluck(:day, :ended_monthly_subscriptions) } ], colors: ['#2196F3'] %> -
    - 90-day net: - <%= sprintf "%+d", @reports.limit(90).sum(:new_monthly_subscriptions) - @reports.limit(90).sum(:ended_monthly_subscriptions) %> +

    + <%= @days %>-day net: + <%= sprintf "%+d", @reports.limit(@days).sum(:new_monthly_subscriptions) - @reports.limit(@days).sum(:ended_monthly_subscriptions) %> subscriptions -

    +

    -
    -
    -
    -
    -
    - 90-day trimonthly subscriptons - + + +
    +
    +
    +

    <%= @days %>-day trimonthly subscriptions

    + <%= number_with_delimiter User.where(selected_billing_plan_id: 5).count %> current total
    <%= area_chart [ - { name: 'Started subscriptions', data: @reports.limit(90).pluck(:day, :new_trimonthly_subscriptions) }, - { name: 'Ended subscriptions', data: @reports.limit(90).pluck(:day, :ended_trimonthly_subscriptions) } + { name: 'Started subscriptions', data: @reports.limit(@days).pluck(:day, :new_trimonthly_subscriptions) }, + { name: 'Ended subscriptions', data: @reports.limit(@days).pluck(:day, :ended_trimonthly_subscriptions) } ], colors: ['#2196F3'] %> -
    - 90-day net: - <%= sprintf "%+d", @reports.limit(90).sum(:new_trimonthly_subscriptions) - @reports.limit(90).sum(:ended_trimonthly_subscriptions) %> +

    + <%= @days %>-day net: + <%= sprintf "%+d", @reports.limit(@days).sum(:new_trimonthly_subscriptions) - @reports.limit(@days).sum(:ended_trimonthly_subscriptions) %> subscriptions -

    +

    -
    -
    -
    -
    -
    - 90-day annual subscriptions - + + +
    +
    +
    +

    <%= @days %>-day annual subscriptions

    + <%= number_with_delimiter User.where(selected_billing_plan_id: 6).count %> current total
    <%= area_chart [ - { name: 'Started subscriptions', data: @reports.limit(90).pluck(:day, :new_annual_subscriptions) }, - { name: 'Ended subscriptions', data: @reports.limit(90).pluck(:day, :ended_annual_subscriptions) } + { name: 'Started subscriptions', data: @reports.limit(@days).pluck(:day, :new_annual_subscriptions) }, + { name: 'Ended subscriptions', data: @reports.limit(@days).pluck(:day, :ended_annual_subscriptions) } ], colors: ['#2196F3'] %> -
    - 90-day net: - <%= sprintf "%+d", @reports.limit(90).sum(:new_annual_subscriptions) - @reports.limit(90).sum(:ended_annual_subscriptions) %> +

    + <%= @days %>-day net: + <%= sprintf "%+d", @reports.limit(@days).sum(:new_annual_subscriptions) - @reports.limit(@days).sum(:ended_annual_subscriptions) %> subscriptions -

    +

    - <% Rails.application.config.content_types[:all].each do |content_type| %> -
    -
    -
    -
    - New <%= content_type.name.pluralize %> each day - <%= number_with_delimiter content_type.last.id %> total + +
    + <% Rails.application.config.content_types[:all].each do |content_type| %> +
    +
    +
    +

    New <%= content_type.name.pluralize %>

    + <%= number_with_delimiter content_type.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, "#{content_type.name.downcase.pluralize}_created") @@ -101,7 +117,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -109,14 +125,14 @@ %>
    -
    - <% end %> -
    -
    -
    -
    - New timelines - <%= number_with_delimiter Timeline.last.id %> total + <% end %> + + +
    +
    +
    +

    New timelines

    + <%= number_with_delimiter Timeline.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :timelines_created) @@ -126,7 +142,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -136,12 +152,14 @@
    -
    -
    -
    -
    - New stream shares - <%= number_with_delimiter ContentPageShare.last.id %> total + +
    + +
    +
    +
    +

    New stream shares

    + <%= number_with_delimiter ContentPageShare.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :stream_shares_created) @@ -151,7 +169,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -159,12 +177,12 @@ %>
    -
    -
    -
    -
    -
    - New stream comments + + +
    +
    +
    +

    New stream comments

    <%= area_chart @reports.pluck(:day, :stream_comments) @@ -174,7 +192,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -184,12 +202,14 @@
    -
    -
    -
    -
    - New collections - <%= number_with_delimiter PageCollection.last.id %> total + +
    + +
    +
    +
    +

    New collections

    + <%= number_with_delimiter PageCollection.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :collections_created) @@ -199,7 +219,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -207,13 +227,13 @@ %>
    -
    -
    -
    -
    -
    - New collection submissions - <%= number_with_delimiter PageCollectionSubmission.last.id %> total + + +
    +
    +
    +

    New collection submissions

    + <%= number_with_delimiter PageCollectionSubmission.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :collection_submissions_created) @@ -223,7 +243,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -233,12 +253,14 @@
    -
    -
    -
    -
    - New discussion threads - <%= number_with_delimiter Thredded::Topic.last.id %> total + +
    + +
    +
    +
    +

    New discussion threads

    + <%= number_with_delimiter Thredded::Topic.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :thredded_threads_created) @@ -248,7 +270,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -256,13 +278,13 @@ %>
    -
    -
    -
    -
    -
    - New discussion replies - <%= number_with_delimiter Thredded::Post.last.id %> total + + +
    +
    +
    +

    New discussion replies

    + <%= number_with_delimiter Thredded::Post.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :thredded_replies_created) @@ -272,7 +294,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -282,12 +304,14 @@
    -
    -
    -
    -
    - New private discussions - <%= number_with_delimiter Thredded::PrivateTopic.last.id %> total + +
    + +
    +
    +
    +

    New private discussions

    + <%= number_with_delimiter Thredded::PrivateTopic.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :thredded_private_messages_created) @@ -297,7 +321,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -305,13 +329,13 @@ %>
    -
    -
    -
    -
    -
    - New private replies - <%= number_with_delimiter Thredded::PrivatePost.last.id %> total + + +
    +
    +
    +

    New private replies

    + <%= number_with_delimiter Thredded::PrivatePost.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :thredded_private_replies_created) @@ -321,7 +345,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -331,12 +355,14 @@
    -
    -
    -
    -
    - New documents - <%= number_with_delimiter Document.last.id %> total + +
    + +
    +
    +
    +

    New documents

    + <%= number_with_delimiter Document.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :documents_created) @@ -346,7 +372,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -354,12 +380,12 @@ %>
    -
    -
    -
    -
    -
    - Updated documents + + +
    +
    +
    +

    Updated documents

    <%= area_chart @reports.pluck(:day, :documents_edited) @@ -369,7 +395,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -377,13 +403,13 @@ %>
    -
    -
    -
    -
    -
    - Document revisions - <%= number_with_delimiter DocumentRevision.last.id %> total + + +
    +
    +
    +

    Document revisions

    + <%= number_with_delimiter DocumentRevision.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :document_revisions_created) @@ -393,7 +419,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, @@ -401,13 +427,13 @@ %>
    -
    -
    -
    -
    -
    - New document analyses - <%= number_with_delimiter DocumentAnalysis.last.id %> total + + +
    +
    +
    +

    New document analyses

    + <%= number_with_delimiter DocumentAnalysis.last&.id || 0 %> total
    <%= area_chart @reports.pluck(:day, :document_analyses_created) @@ -417,7 +443,7 @@ data.each { |date, count| totals_per_month[date.strftime("%b")] += (count || 0) } { - name: year, + name: year, data: Date::ABBR_MONTHNAMES.reject(&:nil?).map { |month| [month, totals_per_month[month]] } } }, diff --git a/app/views/admin/hate.html.erb b/app/views/admin/hate.html.erb index 1530a27a9..7cbcf749c 100644 --- a/app/views/admin/hate.html.erb +++ b/app/views/admin/hate.html.erb @@ -1,35 +1,137 @@ - - - - - - - - - <% @posts.each do |post| %> - - <% triggers = Documents::Analysis::ContentService.adult_content?(@list, post.content) %> - <% next unless triggers.any? %> - - - - +
    + +
    +

    Content Moderation

    +

    + Scanning private messages for flagged content + <% if @list.present? %> + using <%= @list %> filter + <% end %> +

    +
    + + <% + # Pre-filter posts to only those with triggers + flagged_posts = @posts.filter_map do |post| + triggers = Documents::Analysis::ContentService.adult_content?(@list, post.content) + next unless triggers.any? + { post: post, triggers: triggers } + end + %> + + +
    +
    +
    +
    + + + +
    +
    +

    Posts Scanned

    +

    <%= number_with_delimiter @posts.count %>

    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    Flagged Posts

    +

    <%= number_with_delimiter flagged_posts.count %>

    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    Clean Rate

    +

    + <%= @posts.count > 0 ? (100 - (flagged_posts.count / @posts.count.to_f * 100)).round(1) : 100 %>% +

    +
    +
    +
    +
    + + + <% if flagged_posts.any? %> +
    +
    +

    Flagged Messages

    +
    + +
    + <% flagged_posts.each do |item| %> + <% post = item[:post] %> + <% triggers = item[:triggers] %> +
    +
    + +
    +

    + <%= post.postable.title %> +

    + <%= link_to '/forum/private-topics/' + post.postable.slug, class: "text-sm text-blue-600 dark:text-blue-400 hover:underline" do %> + View thread + <% end %> +

    + <%= post.postable.created_at.strftime("%b %d, %Y") %> + · + <%= time_ago_in_words post.postable.created_at %> ago +

    +
    + + +
    +

    Triggers

    +
    + <% triggers.each do |trigger| %> + + <%= trigger %> + + <% end %> +
    +
    + + +
    +

    Content

    +
    + <% + content = post.content.dup + triggers.each do |trigger| + content.gsub!(trigger, "#{trigger}") + end + %> + <%= content.html_safe %> +
    +
    +
    +
    + <% end %> +
    +
    + <% else %> + +
    + + + +

    All clear!

    +

    No flagged content found in the last <%= number_with_delimiter @posts.count %> posts.

    +
    <% end %> -
    ThreadTrigger wordsFull 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 %> -
    \ No newline at end of file +
    diff --git a/app/views/admin/hub.html.erb b/app/views/admin/hub.html.erb new file mode 100644 index 000000000..c5d34e011 --- /dev/null +++ b/app/views/admin/hub.html.erb @@ -0,0 +1,133 @@ +
    +

    Admin Hub

    + + +
    + +
    +

    Background Jobs

    +
    +
    +
    +
    <%= number_with_delimiter(@sidekiq_stats.enqueued) %>
    +
    Enqueued
    +
    +
    +
    <%= number_with_delimiter(@sidekiq_stats.processed) %>
    +
    Processed
    +
    +
    +
    <%= number_with_delimiter(@sidekiq_stats.failed) %>
    +
    Failed
    +
    +
    +
    +
    + <%= link_to "Sidekiq Dashboard", "/sidekiq", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    +
    + + +
    +

    AI / Basil

    +
    +
    +
    +
    <%= number_with_delimiter(@basil_queue_count) %>
    +
    In Queue
    +
    +
    +
    <%= number_with_delimiter(@basil_today_count) %>
    +
    Done Today
    +
    +
    +
    +
    + <%= link_to "Basil Review", "/ai/basil/review", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    +
    +
    + + +
    +
    +

    Words Written

    +
    +
    +
    <%= number_with_delimiter(@words_written_today) %>
    +
    Today
    +
    +
    +
    <%= number_with_delimiter(@words_written_this_week) %>
    +
    This Week
    +
    +
    +
    +
    + +
    + +
    +

    Analytics & Insights

    +
      +
    • + <%= link_to "Dashboard", admin_dashboard_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Churn Analysis", "/admin/churn", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Notifications", "/admin/notifications", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    +
    + + +
    +

    Moderation

    +
      +
    • + <%= link_to "Reported Shares", admin_reported_shares_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Forum Moderation", "/forum/admin/moderation", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Spam Watch", "/admin/spamwatch", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Hate Watch", "/admin/hatewatch/default", class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Image Audit", image_audit_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    +
    + + +
    +

    User Management

    +
      +
    • + <%= link_to "Bulk Unsubscribe", mass_unsubscribe_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    +
    + + +
    +

    Configuration

    +
      +
    • + <%= link_to "Attributes", admin_attributes_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Promo Codes", admin_promos_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    • + <%= link_to "Rails Admin", rails_admin_path, class: "text-blue-600 dark:text-blue-400 hover:underline" %> +
    • +
    +
    +
    +
    diff --git a/app/views/admin/images.html.erb b/app/views/admin/images.html.erb index 7c1ce408f..a540c9b57 100644 --- a/app/views/admin/images.html.erb +++ b/app/views/admin/images.html.erb @@ -1,17 +1,98 @@ -<% @images.each do |image| %> -
    -
    - <%= link_to image.src(:original) do %> - <%= image_tag image.src(:medium), alt: image.id %> +
    + +
    +

    Image Uploads

    +

    + 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' %> -

    +

    -<% end %> -
    -

    - <%= link_to 'Previous page', params.permit(:page, :content_type).merge({page: params[:page].to_i - 1}) %> | - <%= link_to 'Next page', params.permit(:page, :content_type).merge({page: params[:page].to_i + 1}) %> -

    \ No newline at end of file + + <% if @images.any? %> +
    + <% @images.each do |image| %> +
    + +
    + <%= link_to image.src(:original), target: '_blank', rel: 'noopener noreferrer' do %> + <%= image_tag image.src(:medium), + alt: "Image #{image.id}", + class: 'w-full h-full object-cover group-hover:opacity-75 transition-opacity', + loading: 'lazy' %> + <% end %> +
    + +
    + <% if image.content.present? %> + <%= link_to image.content, class: 'text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 hover:underline truncate block' do %> + <%= image.content.try(:name).presence || 'Untitled' %> + <% end %> +
    + <%= image.content.class.name.demodulize %> + + <%= image.content.try(:privacy) || 'unknown' %> + +
    + <% else %> + No linked content + <% end %> +
    +
    + <% end %> +
    + <% else %> +
    + + + +

    No images found

    +
    + <% end %> + + +
    +
    + <% if params[:page].to_i > 0 %> + <%= link_to 'Previous', + params.permit(:page, :content_type).merge(page: params[:page].to_i - 1), + class: 'inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600' %> + <% else %> + + Previous + + <% end %> + + <% if @images.size == 500 %> + <%= link_to 'Next', + params.permit(:page, :content_type).merge(page: params[:page].to_i + 1), + class: 'inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600' %> + <% else %> + + Next + + <% end %> +
    + +
    +
    diff --git a/app/views/admin/notification_reference.html.erb b/app/views/admin/notification_reference.html.erb new file mode 100644 index 000000000..0d6ed103c --- /dev/null +++ b/app/views/admin/notification_reference.html.erb @@ -0,0 +1,322 @@ +
    + +
    +
    + <%= link_to '/admin/notifications', class: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300' do %> + + + + <% end %> +

    <%= @reference_code %>

    +
    +

    + Analytics for notifications with this reference code +

    +
    + + <% + # Helper to format seconds into human-readable time + def format_duration(seconds) + return "N/A" if seconds.nil? + if seconds < 60 + "#{seconds} seconds" + elsif seconds < 3600 + "#{(seconds / 60.0).round(1)} minutes" + elsif seconds < 86400 + "#{(seconds / 3600.0).round(1)} hours" + else + "#{(seconds / 86400.0).round(1)} days" + end + end + %> + + +
    + +
    +
    +
    + + + +
    +
    +

    Total Sent

    +

    <%= number_with_delimiter @total_sent %>

    +
    +
    +
    + + +
    +
    +
    + + + +
    +
    +

    Total Clicked

    +

    <%= number_with_delimiter @total_clicked %>

    +
    +
    +
    + + + <% + rate_bg = @click_rate >= 20 ? 'bg-green-100 dark:bg-green-900' : @click_rate >= 10 ? 'bg-yellow-100 dark:bg-yellow-900' : 'bg-red-100 dark:bg-red-900' + rate_icon = @click_rate >= 20 ? 'text-green-600 dark:text-green-400' : @click_rate >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' + rate_text = @click_rate >= 20 ? 'text-green-600 dark:text-green-400' : @click_rate >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' + %> +
    +
    +
    + + + +
    +
    +

    Click Rate

    +

    <%= @click_rate %>%

    +
    +
    +
    + + +
    +
    +
    + + + +
    +
    +

    Unique Users

    +

    <%= number_with_delimiter @unique_users %>

    +
    +
    +
    +
    + + + <% if @has_time_stats %> +
    + +
    +
    +
    + + + +
    +
    +

    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) %>

    +
    +
    +
    +
    + <% end %> + + + <% if @first_notification && @last_notification %> +
    +
    +

    Campaign Timeline

    +
    +
    +

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

    +
    +
    +
    +
    + <% end %> + + +
    +
    +

    Notification Trend

    + <%= + line_chart [ + { name: "Sent", data: @sent_by_month }, + { name: "Clicked", data: @clicked_by_month } + ], colors: ['#3B82F6', '#10B981'] + %> +
    +
    + + + <% if @link_stats.any? %> +
    +

    Link Performance (Top 50 by Volume)

    + +
    + + + + + + + + + + + <% @link_stats.each do |stats| %> + <% + total = stats.total_count.to_i + clicked = stats.clicked_count.to_i + link_rate = total > 0 ? (clicked / total.to_f * 100).round(1) : 0 + link_text_color = link_rate >= 20 ? 'text-green-600 dark:text-green-400' : link_rate >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' + %> + + + + + + + <% end %> + + +
    +
    + <% end %> + + + <% if @sample_notification %> +
    +

    Sample Message

    +
    +
    +
    + <%= @sample_notification.icon %> +
    +
    +

    <%= raw @sample_notification.message_html %>

    + <% if @sample_notification.passthrough_link.present? %> +

    + Links to: <%= @sample_notification.passthrough_link %> +

    + <% end %> +
    +
    +
    +
    + <% end %> +
    + + diff --git a/app/views/admin/notifications.html.erb b/app/views/admin/notifications.html.erb index 7f91e1b18..f00aa4b8b 100644 --- a/app/views/admin/notifications.html.erb +++ b/app/views/admin/notifications.html.erb @@ -1,28 +1,425 @@ -

    - Notification open rates -

    - -
    - <%= number_with_delimiter @clicked_notifications.count %> clicked - / - <%= number_with_delimiter @notifications.count %> total - (<%= @clicked_notifications.count / @notifications.count.to_f * 100 %>%) -
    +
    + +
    +

    Notification Analytics

    +

    + Track open rates and engagement across notification campaigns +

    +
    + + +
    + <%= form_tag(nil, method: :get, id: 'reference-code-search', class: 'flex gap-4') do %> +
    + +
    +
    + + + +
    + + + <% @codes.compact.each do |code| %> + +
    +
    + + <% end %> +
    + + + + <% + total_count = @total_count + clicked_count = @clicked_count + overall_rate = total_count > 0 ? (clicked_count / total_count.to_f * 100).round(1) : 0 -<% @codes.each do |code| %> -
    -
    -
    Reference code: <%= code.inspect %>
    - <% - clicked_notifs = Notification.where(reference_code: code).where.not(viewed_at: nil).count - total_notifs = Notification.where(reference_code: code).count + # Use pre-calculated average from controller (calculated in SQL) + avg_seconds = @avg_seconds + if avg_seconds.present? && avg_seconds > 0 + if avg_seconds < 60 + avg_time_display = "#{avg_seconds} seconds" + elsif avg_seconds < 3600 + avg_time_display = "#{(avg_seconds / 60.0).round(1)} minutes" + elsif avg_seconds < 86400 + avg_time_display = "#{(avg_seconds / 3600.0).round(1)} hours" + else + avg_time_display = "#{(avg_seconds / 86400.0).round(1)} days" + end + else + avg_time_display = "N/A" + end + %> + + +
    + +
    +
    +
    + + + +
    +
    +

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

    +
    +
    +
    +
    + + +
    +
    +

    Notification Trend (Last 12 Months)

    + <%= + line_chart [ + { name: "Sent", data: @sent_by_month }, + { name: "Clicked", data: @clicked_by_month } + ], colors: ['#3B82F6', '#10B981'] %> +
    +
    -
      -
    • <%= number_with_delimiter clicked_notifs %> clicked
    • -
    • <%= number_with_delimiter total_notifs %> total
    • -
    • <%= clicked_notifs / total_notifs.to_f * 100 %>% clicked
    • -
    + +
    +
    +

    By Reference Code (Top 50 by Volume)

    + + +
    + Sort by: +
    + + + + +
    +
    + + <% if @code_stats.any? %> +
    + <% @code_stats.each do |stats| %> + <% + total = stats.total_count.to_i + clicked = stats.clicked_count.to_i + rate = total > 0 ? (clicked / total.to_f * 100).round(1) : 0 + bar_color = rate >= 20 ? 'bg-green-500' : rate >= 10 ? 'bg-yellow-500' : 'bg-red-500' + text_color = rate >= 20 ? 'text-green-600 dark:text-green-400' : rate >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' + %> + <% if stats.reference_code.present? %> + <%= link_to admin_notification_reference_path(stats.reference_code), + class: "reference-code-card block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer", + data: { volume: total, rate: rate, clicked: clicked } do %> +
    + +
    +

    + <%= stats.reference_code %> +

    + <%= rate %>% +
    + + +
    +
    +
    + + +
    + <%= number_with_delimiter clicked %> clicked + <%= number_with_delimiter total %> sent +
    +
    + <% end %> + <% else %> +
    +
    + +
    +

    + (no code) +

    + <%= rate %>% +
    + + +
    +
    +
    + + +
    + <%= number_with_delimiter clicked %> clicked + <%= number_with_delimiter total %> sent +
    +
    +
    + <% end %> + <% end %> +
    + <% else %> +
    +

    No reference codes found.

    +
    + <% end %>
    -<% end %> \ No newline at end of file + + + <% if @link_stats.any? %> +
    +

    Link Performance (Top 50 by Volume)

    + +
    + + + + + + + + + + + <% @link_stats.each do |stats| %> + <% + total = stats.total_count.to_i + clicked = stats.clicked_count.to_i + rate = total > 0 ? (clicked / total.to_f * 100).round(1) : 0 + text_color = rate >= 20 ? 'text-green-600 dark:text-green-400' : rate >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' + %> + + + + + + + <% end %> + + +
    +
    + <% end %> + + +
    +

    Recent Activity

    + + <% recent_notifications = Notification.order(created_at: :desc).limit(20) %> + + <% if recent_notifications.any? %> +
    +
      + <% recent_notifications.each do |notification| %> +
    • +
      +
      + + <% if notification.viewed_at.present? %> + + <% else %> + + <% end %> + + +
      +

      + <%= strip_tags(notification.message_html).truncate(80) if notification.message_html.present? %> +

      +
      + <% if notification.reference_code.present? %> + + <%= notification.reference_code %> + + <% end %> + + <%= time_ago_in_words(notification.created_at) %> ago + +
      +
      +
      + + +
      + <% if notification.viewed_at.present? %> + + Clicked + + <% else %> + + Pending + + <% end %> +
      +
      +
    • + <% end %> +
    +
    + <% else %> +
    +

    No recent notifications.

    +
    + <% end %> +
    +
    + + diff --git a/app/views/admin/perform_unsubscribe.html.erb b/app/views/admin/perform_unsubscribe.html.erb index 048b45371..db870cdfd 100644 --- a/app/views/admin/perform_unsubscribe.html.erb +++ b/app/views/admin/perform_unsubscribe.html.erb @@ -1,31 +1,76 @@ -
    -
    - The following users have been unsubscribed: - - +
    + +
    +

    Unsubscribe Complete

    +

    + The following users have been moved to the Starter plan +

    +
    + + +
    +
    + + + +
    +

    + Successfully unsubscribed <%= @users.count %> user<%= @users.count == 1 ? '' : 's' %>. +

    +
    +
    +
    + + +
    +
    + - - - - - + + + + + - - + <% @users.each do |user| %> - - - - - - + + + + + + <% end %>
    User IDEmailNameLast 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' %> +
    +
    + +
    + <%= link_to 'Unsubscribe more users', mass_unsubscribe_path, class: 'inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-lg shadow-sm text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500' %>
    - -<%= link_to 'Unsubscribe more users', mass_unsubscribe_path, class: 'btn white black-text' %> diff --git a/app/views/admin/promos.html.erb b/app/views/admin/promos.html.erb index 21bf67a53..709a74414 100644 --- a/app/views/admin/promos.html.erb +++ b/app/views/admin/promos.html.erb @@ -1,40 +1,156 @@ - - - - - - - - - - - - <% @codes.each do |code| %> - - - - - - - - - - +
    +

    Total Codes

    +

    <%= number_with_delimiter total_codes %>

    +
    + + + +
    +
    +
    + + + +
    +
    +

    Total Redeemed

    +

    <%= number_with_delimiter total_used %>

    +
    +
    +
    + +
    +
    +
    + + + +
    +
    +

    Currently Active

    +

    <%= number_with_delimiter total_active %>

    +
    +
    +
    + + + + <% if @codes.any? %> +
    +
    +

    All Promo Codes

    +
    + +
    +
    IDCodeInternal descriptionPublic descriptionAvailabilityActive nowPages unlockedDays 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) %> -
    -
    +
    + +
    +

    Promo Codes

    +

    + Manage page unlock promotional codes and track their usage +

    +
    + + <% + 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 } + %> + + +
    +
    +
    +
    + + +
    -
      -
    • <%= used %> used
    • -
    • <%= code.uses_remaining %> remaining
    • -
    • <%= used + code.uses_remaining %> originally available
    • -
    -

    <%= 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
    + + + + + + + + + + + + + <% @codes.each do |code| %> + <% + used = code.promotions.pluck(:user_id).uniq.count + total = used + code.uses_remaining + ratio = total > 0 ? (code.uses_remaining * 100.0) / total : 0 + active_count = code.promotions.active.pluck(:user_id).uniq.count + %> + + + + + + + + + + <% end %> + +
    IDCodeDescriptionAvailabilityActive NowPage TypesDuration
    + <%= 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 +
    +
    +
    + <% else %> + +
    + + + +

    No promo codes

    +

    No promotional codes have been created yet.

    +
    <% end %> - \ No newline at end of file +
    diff --git a/app/views/admin/reported_shares.html.erb b/app/views/admin/reported_shares.html.erb index c4167f6e2..1cfb18000 100644 --- a/app/views/admin/reported_shares.html.erb +++ b/app/views/admin/reported_shares.html.erb @@ -1,10 +1,150 @@ -<% @feed.each do |share| %> - <%= render partial: 'stream/share', locals: { share: share } %> -
    - ID: <%= share.id %> -
    -<% end %> - -<% if @feed.empty? %> - We're all caught up! -<% end %> \ No newline at end of file +
    + +
    +

    Reported Shares

    +

    + <%= pluralize(@feed.total_entries, 'share') %> pending review +

    +
    + + + <% if notice.present? %> +
    +
    + + + +

    <%= notice %>

    +
    +
    + <% end %> + + <% if @feed.any? %> +
    + <%= will_paginate @feed, class: 'mb-6' %> + <% @feed.each do |share| %> +
    + +
    +
    +
    + + + + + Reported <%= pluralize(share.content_page_share_reports.count, 'time') %> + +
    +
    + by + <% share.content_page_share_reports.each_with_index do |report, index| %> + <%= link_to report.user.display_name, report.user, class: "font-medium hover:underline" %><%= ', ' unless index == share.content_page_share_reports.count - 1 %> + <% end %> +
    +
    +
    + +
    + +
    + <%= image_tag share.user.image_url(size: 48), class: "h-12 w-12 rounded-full flex-shrink-0" %> +
    +
    + <%= link_to share.user, class: "font-semibold text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400" do %> + <%= share.user.display_name %> + <% end %> + @<%= share.user.username %> +
    +
    + Shared <%= time_ago_in_words(share.created_at) %> ago + | + User since <%= share.user.created_at.strftime('%b %Y') %> + | + <%= pluralize(share.user.content_page_shares.count, 'share') %> total +
    +
    +
    + Share #<%= share.id %>
    + User #<%= share.user.id %> +
    +
    + + + <% if share.message.present? %> +
    +

    <%= share.message %>

    +
    + <% end %> + + + <% if share.content_page %> +
    + <% if share.content_page.class.respond_to?(:hex_color) %> +
    + <%= share.content_page.class.icon %> +
    + <% end %> +
    +

    <%= share.content_page.name %>

    +

    <%= share.content_page.class.name %>

    +
    + <%= link_to share.content_page, class: "text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 text-sm font-medium" do %> + View content + + + + <% end %> +
    + <% else %> +
    + Linked content has been deleted +
    + <% end %> + + +
    + <%= link_to user_content_page_share_path(share.user, share), class: "inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" do %> + + + + + View Share + <% end %> + + <%= link_to admin_dismiss_share_reports_path(share), method: :post, class: "inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors" do %> + + + + Dismiss Reports + <% end %> + + <%= link_to admin_destroy_share_path(share), method: :delete, data: { confirm: "Are you sure you want to delete this share? This cannot be undone." }, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors" do %> + + + + Delete Share + <% end %> + + <%= link_to admin_destroy_user_path(share.user), method: :delete, data: { confirm: "Are you sure you want to permanently delete user '#{share.user.display_name}' and ALL their content? This cannot be undone." }, class: "inline-flex items-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg text-sm font-medium text-red-600 dark:text-red-400 bg-white dark:bg-gray-700 hover:bg-red-50 dark:hover:bg-red-900 transition-colors" do %> + + + + Delete User + <% end %> +
    +
    +
    + <% end %> + <%= will_paginate @feed, class: 'mt-6' %> +
    + <% else %> + +
    + + + +

    All caught up!

    +

    No reported shares to review.

    +
    + <% end %> +
    diff --git a/app/views/admin/spam.html.erb b/app/views/admin/spam.html.erb index 988c73925..3eda53b48 100644 --- a/app/views/admin/spam.html.erb +++ b/app/views/admin/spam.html.erb @@ -1,36 +1,81 @@ - - - - - - - - +
    + +
    +

    Spam Watch

    +

    + Private posts containing links that may be spam +

    +
    - <% @posts.each do |post| %> - <% links = post.content.scan(/([htp]+?s?:?\/\/[^\s\)"']*)/i) %> - <% next unless links.any? %> -
    - - - +
    ThreadLinksImagesFull text
    - <%= post.postable.title %> (<%= post.postable.slug %>) - -
      - <% links.each do |url| %> - <%= link_to url.first, url.first %> - <% end %> -
    -
    - <% links.each do |url| %> - <%= link_to url.first do %> - <%= image_tag url.first, class: 'left', style: "height: 200px; width: 200px" %> + +
    +
    + + + + + + + + + + + <% posts_with_links = @posts.select { |post| post.content.scan(/([htp]+?s?:?\/\/[^\s\)"']*)/i).any? } %> + <% if posts_with_links.empty? %> + + + + <% else %> + <% posts_with_links.each do |post| %> + <% links = post.content.scan(/([htp]+?s?:?\/\/[^\s\)"']*)/i) %> + + + + + + <% end %> <% end %> - - - - <% end %> -
    + Thread + + Links + + Images + + Full Text +
    + No posts with links found +
    +
    <%= post.postable.title %>
    +
    <%= post.postable.slug %>
    +
    +
      + <% links.each do |url| %> +
    • + <%= link_to url.first, url.first, class: 'text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 hover:underline', target: '_blank', rel: 'noopener noreferrer' %> +
    • + <% end %> +
    +
    +
    + <% links.each do |url| %> + <%= link_to url.first, target: '_blank', rel: 'noopener noreferrer' do %> + <%= image_tag url.first, class: 'h-24 w-24 object-cover rounded-lg border border-gray-200 dark:border-gray-600 hover:opacity-75 transition-opacity', onerror: "this.style.display='none'" %> + <% end %> + <% end %> +
    +
    +
    +

    <%= post.content %>

    +
    +
    - <%= post.content %> -
    \ No newline at end of file +
    +
    +
    + + +
    + Showing <%= posts_with_links.count %> post<%= posts_with_links.count == 1 ? '' : 's' %> with links +
    +
    diff --git a/app/views/admin/unsubscribe.html.erb b/app/views/admin/unsubscribe.html.erb index 815913428..3149ae492 100644 --- a/app/views/admin/unsubscribe.html.erb +++ b/app/views/admin/unsubscribe.html.erb @@ -1,14 +1,53 @@ -<%= form_tag(perform_unsubscribe_path, method: :POST) do %> -
    -
    -
    - - - One email per line -
    -
    +
    + +
    +

    Mass Unsubscribe

    +

    + Move users to the Starter plan in bulk +

    - <%= submit_tag 'Move the above users to the Starter plan', class: 'btn red' %>
    - This will NOT remove the user from their current plan on Stripe, but WILL send a "You have been unsubscribed" email. -<% end %> + +
    +
    + <%= form_tag(perform_unsubscribe_path, method: :POST) do %> +
    +
    + + +

    + 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. +

    +
    +
    +
    + + +
    + <%= submit_tag 'Move users to Starter plan', class: 'inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 cursor-pointer' %> +
    +
    + <% end %> +
    +
    +
    diff --git a/app/views/basil/_header.html.erb b/app/views/basil/_header.html.erb new file mode 100644 index 000000000..dd3aadb3c --- /dev/null +++ b/app/views/basil/_header.html.erb @@ -0,0 +1,37 @@ +<%# + Basil header partial + Local variables: + - subtitle: (optional) Custom subtitle text, defaults to standard intro + - show_rating_link: (optional) Show link to rating queue, defaults to true +%> +<% subtitle ||= "I can help you visualize your characters and world." %> +<% show_rating_link = true if local_assigns[:show_rating_link].nil? %> + +
    +
    +
    +
    + +
    +
    +
    +
    + <%= image_tag 'basil/portrait.png', class: 'relative w-32 h-32 md:w-40 md:h-40 rounded-full border-4 border-white border-opacity-20 shadow-2xl ring-4 ring-purple-500 ring-opacity-20 object-cover' %> +
    +
    +
    +

    + Hey, I'm Basil. +

    +

    + <%= subtitle %> +

    + <% if show_rating_link %> +

    + 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 %> +
    +
    +
    diff --git a/app/views/basil/content.html.erb b/app/views/basil/content.html.erb index c6ecfed0f..38efa9d46 100644 --- a/app/views/basil/content.html.erb +++ b/app/views/basil/content.html.erb @@ -1,447 +1,790 @@ - - -
    - <%= form_for BasilCommission.new, url: basil_commission_path(@content_type, @content.id) do |f| %> - <%= f.hidden_field :style, value: 'realistic' %> - <%= f.hidden_field :entity_type, value: @content.page_type %> - <%= f.hidden_field :entity_id, value: @content.id %> -
    -
    -
    - <%= link_to basil_path, class: 'grey-text text-darken-2' do %> - Basil + +
    + +
    +
    +
    +
    -
    - <%= image_tag @content.random_image_including_private(format: :medium), style: 'width: 100%' %> -

    - <%= link_to @content.name, @content.view_path, class: @content.text_color %> -

    - <%= link_to @content.edit_path, class: 'grey-text text-darken-2', style: 'margin-bottom: 1rem; display: inline-block;' do %> - Edit this <%= @content.page_type.downcase %> + chevron_right + <%= @content.name %> + + +
    + <%= link_to @content.view_path, class: 'inline-flex items-center px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors' do %> + visibility + + View + <% end %> + <%= link_to @content.edit_path, class: 'inline-flex items-center px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors' do %> + edit + Edit <% end %>
    +
    +
    +
    -
      - <% @relevant_fields.each do |field, value| %> - <%= f.hidden_field "field[#{field.id}][label]", value: field.label %> - <%= f.hidden_field "field[#{field.id}][value]", value: value %> + <%= form_for BasilCommission.new, url: basil_commission_path(@content_type, @content.id), html: { id: 'basil-commission-form' } do |f| %> + <%= f.hidden_field :style, value: 'realistic', id: 'basil_commission_style' %> + <%= f.hidden_field :entity_type, value: @content.page_type %> + <%= f.hidden_field :entity_id, value: @content.id %> + +
      +
      + + +
      + +
      + <% if @content.random_image_including_private(format: :medium) %> + <%= image_tag @content.random_image_including_private(format: :medium), + class: 'w-full h-32 sm:h-40 lg:h-48 object-cover bg-gray-100 dark:bg-gray-700' %> + <% end %> -
    • -
      -
      -
      - <%= field.label %> - <%= range_field_tag "basil_commission[field][#{field.id}][importance]", @guidance.fetch(field.id.to_s, 1), { min: 0, max: 1.3, step: 0.1, style: 'width: 60%;', class: 'js-importance-slider hide' } %> +
      +
      +
      +
      + <%= @content.icon %> + <%= @content.page_type %>
      -
      -
      - <%= value %> +

      <%= @content.name %>

      -
    • - <% end %> -
    - - <% if @additional_fields.any? %> - <% has_previously_used_fields = @additional_fields.any? { |field, _| @guidance.key?(field.id.to_s) } %> - <% if @relevant_fields.any? %> - <%# Show collapsed when there ARE primary fields (unless user has previously selected additional fields) %> -
    - - <%= has_previously_used_fields ? 'remove_circle_outline' : 'add_circle_outline' %> - Show <%= pluralize(@additional_fields.count, 'more field') %> you can include - <%= has_previously_used_fields ? 'expand_less' : 'expand_more' %> - - -
    -
      - <% @additional_fields.each do |field, value| %> - <% field_previously_used = @guidance.key?(field.id.to_s) %> -
    • - -
      <%= truncate(value, length: 140) %>
      -
      - <%= hidden_field_tag "basil_commission[field][#{field.id}][label]", field.label, disabled: !field_previously_used %> - <%= hidden_field_tag "basil_commission[field][#{field.id}][value]", value, disabled: !field_previously_used %> - <%= range_field_tag "basil_commission[field][#{field.id}][importance]", @guidance.fetch(field.id.to_s, 1), { min: 0, max: 1.3, step: 0.1, style: 'width: 60%;', disabled: !field_previously_used, class: 'js-importance-slider hide' } %> + + +
      + <% if @relevant_fields.empty? %> +
      +
      + warning +
      +

      More details needed

      +

      + <%= link_to 'Answer more prompts', @content.edit_path, class: 'underline hover:no-underline' %> + to unlock image generation for this page. +

      -
    • +
    +
    + <% else %> +
    +
    +

    Field Details

    + +
    + +
    + <% @relevant_fields.each_with_index do |(field, value), index| %> + <%= f.hidden_field "field[#{field.id}][label]", value: field.label %> + <%= f.hidden_field "field[#{field.id}][value]", value: value %> + +
    +
    +
    + +

    + <%= value.presence || "—" %> +

    +
    + + + +
    +
    + <% end %> +
    +
    + + + + + + <% if @additional_fields.any? %> + <% has_previously_used = @additional_fields.any? { |f, _| @guidance.key?(f.id.to_s) } %> + +
    + + +
    + <% @additional_fields.each do |field, value| %> + <% previously_used = @guidance.key?(field.id.to_s) %> +
    + +
    + <%= hidden_field_tag "basil_commission[field][#{field.id}][label]", field.label, disabled: !previously_used, data: { field_input: field.id } %> + <%= hidden_field_tag "basil_commission[field][#{field.id}][value]", value, disabled: !previously_used, data: { field_input: field.id } %> + <%= range_field_tag "basil_commission[field][#{field.id}][importance]", + @guidance.fetch(field.id.to_s, 1), + { min: 0, max: 1.3, step: 0.1, + class: 'hidden w-16 basil-importance-range', + disabled: !previously_used, + data: { field_input: field.id } } %> +
    +
    + <% end %> +
    +
    <% end %> - + <% end %>
    - <% else %> - <%# Show expanded when there are NO primary fields - these are the only fields available %> -
    -
    - check_box_outline_blank - Select fields to include in your image: +
    + + + <% unless current_user.on_premium_plan? %> +
    +
    + auto_awesome +
    +

    Free Tier

    +

    + <%= @generated_images_count %> of <%= BasilService::FREE_IMAGE_LIMIT %> free images used +

    +
    +
    +
    + <% if @generated_images_count >= BasilService::FREE_IMAGE_LIMIT %> + <%= link_to subscription_path, class: 'inline-flex items-center text-xs font-medium bg-white text-purple-700 px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg hover:bg-purple-50 transition-colors' do %> + Upgrade for unlimited + arrow_forward + <% end %> + <% end %> +
    -
      - <% @additional_fields.each do |field, value| %> - <% field_previously_used = @guidance.key?(field.id.to_s) %> -
    • - -
      <%= truncate(value, length: 140) %>
      -
      - <%= hidden_field_tag "basil_commission[field][#{field.id}][label]", field.label, disabled: !field_previously_used %> - <%= hidden_field_tag "basil_commission[field][#{field.id}][value]", value, disabled: !field_previously_used %> - <%= range_field_tag "basil_commission[field][#{field.id}][importance]", @guidance.fetch(field.id.to_s, 1), { min: 0, max: 1.3, step: 0.1, style: 'width: 60%;', disabled: !field_previously_used, class: 'js-importance-slider hide' } %> -
      -
    • - <% end %> -
    <% end %> - <% end %> +
    + + +
    + <% if @relevant_fields.empty? %> + +
    +
    + +
    + auto_awesome +
    - <% if @relevant_fields.empty? && @additional_fields.empty? %> -
    - Basil works best with guidance! -

    - Please <%= link_to 'fill out more fields', @content.edit_path %> for this page - before requesting an image. -
    - <% end %> + +

    + Let's Add Some Details +

    -
    - <% if @can_request_another && (@relevant_fields.any? || @additional_fields.any?) %> - Customize importance - <% end %> + +

    + Basil relies on your visual descriptions to generate images. + Try adding details to at least one of these fields: +

    -
    - How to customize per-field importance -

    - - Customizing importance allows you to tell Basil which fields are more or less important to you. For example, if Basil is - focusing too hard on something specific you've said, you can turn down the importance of that field with - the slider. -

    - You can also tell Basil to ignore your answer to a field entirely by dragging the slider all the way to the left. -

    - Your preferences for this page are saved whenever you request an image and will be used for all future images for your <%= @content_type.downcase.pluralize %>. -
    -
    + +
    +

    + lightbulb + Suggested Fields to Get Started +

    +
    + <% @suggested_fields.each do |field_name, section| %> +
    + edit +
    + <%= field_name %> + <%= section %> +
    +
    + <% end %> +
    +
    - -
    + + <%= link_to @content.edit_path, + class: "inline-flex items-center px-8 py-3.5 text-base font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-all shadow-sm hover:shadow-md transform hover:scale-105" do %> + edit + Edit <%= @content.name || @content.page_type %> + <% end %> -
    - <% unless current_user.on_premium_plan? %> -
    - - Image generation is a Premium-only feature, but free accounts can still generate up - to <%= pluralize BasilService::FREE_IMAGE_LIMIT, 'image' %> for free. - -

    - You have generated <%= pluralize @generated_images_count, 'image' %> - and have <%= pluralize [0, BasilService::FREE_IMAGE_LIMIT - @generated_images_count].max, 'free image' %> remaining: -
    -
    + +

    + Add details to any of these fields, then return here to start generating images +

    +
    - - <% if @generated_images_count >= BasilService::FREE_IMAGE_LIMIT %> - <%= link_to 'Click here to manage your billing plan', subscription_path, class: 'blue-text text-darken-4' %> - <% end %> -
    - <% end %> -
    -
    - <% end %> - -
    - <%= render partial: 'notice_dismissal/messages/19' if show_notice?(id: 19) %> - - <% if @can_request_another && (@relevant_fields.any? || @additional_fields.any?) %> -
    -
    Image Styles
    - <% BasilService.enabled_styles_for(@content.page_type).each do |style| %> -
    - <%= link_to "javascript:commission_basil('#{style}')", - class: "waves-effect waves-light purple lighten-1 white-text hoverable", - style: "display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; min-height: 120px; text-align: center; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; box-shadow: 0 2px 5px rgba(0,0,0,0.1); text-decoration: none; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;" do %> - palette - <%= style.humanize %> - <% end %> -
    - <% end %> - <% BasilService.experimental_styles_for(@content.page_type).each do |style| %> -
    - <%= link_to "javascript:commission_basil('#{style}')", - class: "waves-effect waves-light purple lighten-3 white-text hoverable", - style: "display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; min-height: 120px; text-align: center; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; box-shadow: 0 2px 5px rgba(0,0,0,0.1); text-decoration: none; transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;" do %> - science - <%= style.humanize %> (Experimental) - <% end %> -
    - <% end %> -
    Click a style to generate an image in that style
    -
    - <% end %> - - <% if !@can_request_another && @in_progress_commissions.any? %> -
    - hourglass_top -
    -
    - Basil is currently working on <%= pluralize @in_progress_commissions.count, 'commission' %> for you. -
    -

    - As soon as one is complete, you'll be able to request another. -

    -
    -
    - <% end %> - -
    - <% @commissions.each do |commission| %> -
    -
    - <% if commission.complete? %> -
    -
    - <%= link_to commission.image do %> - <%= image_tag commission.image %> - <% end %> -
    -
    -
    - <%= @content.name %> - <% if commission.style? %> - (<%= commission.style.humanize %>) + <% elsif @can_request_another && @relevant_fields.any? %> +
    +

    + palette + Choose a Style +

    + + +
    + <% BasilService.enabled_styles_for(@content.page_type).each do |style| %> + + <% end %> +
    + + + <% if BasilService.experimental_styles_for(@content.page_type).any? %> +
    +

    + science + Experimental Styles +

    +
    + <% BasilService.experimental_styles_for(@content.page_type).each do |style| %> + <% end %>
    -
    - Completed <%= time_ago_in_words commission.completed_at %> ago - ·
    - Took <%= distance_of_time_in_words commission.completed_at - commission.created_at %> -
    -
    -
    Feedback for Basil
    -
    - <%= form_for commission.basil_feedbacks.find_or_initialize_by(user: current_user), url: basil_feedback_path(commission.job_id), method: :POST, remote: true do |f| %> -
    - -
    -
    - -
    -
    - +
    + <% end %> + +

    + Click any style to generate an image +

    +
    + <% elsif !@can_request_another && @in_progress_commissions.any? %> + +
    +
    +
    + hourglass_empty +
    +
    +

    Generation in Progress

    +

    + Basil is working on <%= pluralize @in_progress_commissions.count, 'image' %>. + New requests will be available once these complete. +

    +
    +
    +
    + <% end %> +
    + + + <% unless @relevant_fields.empty? %> +
    +
    + <% if @commissions.any? %> +

    + collections + Generated Images +

    + <% end %> + + +
    + <% @commissions.each do |commission| %> + <% if commission.complete? %> + +
    +
    + +
    + +
    +
    +
    +

    <%= @content.name %>

    +

    + <%= commission.style.try(:humanize) %> • + <%= time_ago_in_words commission.completed_at %> ago +

    -
    -
    + + + <% existing_feedback = commission.basil_feedbacks.find_by(user: current_user) %> +
    + <% [ + [-2, 'sentiment_very_dissatisfied', 'text-red-500', 'hover:text-red-400'], + [-1, 'sentiment_dissatisfied', 'text-orange-500', 'hover:text-orange-400'], + [1, 'sentiment_satisfied', 'text-yellow-500', 'hover:text-yellow-400'], + [2, 'sentiment_very_satisfied', 'text-green-500', 'hover:text-green-400'], + [3, 'favorite', 'text-pink-500', 'hover:text-pink-400'] + ].each do |score, icon, color, hover_color| %> + -
    - <% end %> + <% end %> +
    + + +
    + <% if commission.saved_at? %> + + check_circle + Saved + + <% else %> + + <% end %> + + +
    -
    -
    - <% if commission.saved_at? %> - <%= link_to 'Saved', commission.entity, class: 'blue-text' %> - <% else %> - <%= link_to "Save to page", '#', class: 'js-save-commission purple-text', data: { endpoint: basil_save_path(commission) } %> - <% end %> - <%= link_to "Delete", '#', class: 'js-delete-commission red-text right right-align', style: 'margin-right: 0', data: { endpoint: basil_delete_path(commission) } %> -
    -
    - <% else %> -
    -
    -
    -
    -
    -
    -
    -
    -
    + <% else %> + +
    +
    +
    +
    +
    +
    +

    In queue...

    +

    + <%= commission.style.try(:humanize) || 'Style' %> • + Started <%= time_ago_in_words commission.created_at %> ago +

    +

    + Please refresh to see updates +

    +
    -
    -
    Working on it!
    -

    - Basil is crafting your image in the <%= commission.style.try(:humanize) || 'selected' %> style. -

    -

    - Requested <%= time_ago_in_words(commission.created_at) %> ago.
    Please refresh for updates. + <% end %> + <% end %> +

    + + <% if @commissions.count == 10 %> +
    +

    + Showing last 10 images. + <%= link_to 'View all in feedback center', basil_rating_queue_path, class: 'text-purple-600 hover:text-purple-700 font-medium' %>

    <% end %>
    - <% end %> + <% end %> +
    + <% end %> +
    - <% if @commissions.count == 10 %> -
    -
    End of the list?
    -

    - Only your 10 most recent generations are displayed here, but you can still find all - of your generated images on the <%= link_to 'Basil Feedback', basil_rating_queue_path %> pages. -

    -
    - <% end %> + + -<% content_for :javascript do %> -$(document).ready(function() { - $('.js-save-commission').click(function(e) { - $(this).text('Saved!'); + - .basil-loading-card { - animation: pulse-border 2s infinite; - } + -<% end %> \ No newline at end of file +/* Loading spinner animation */ +@keyframes spin { + to { transform: rotate(360deg); } +} +.animate-spin { + animation: spin 1s linear infinite; +} + +/* Responsive adjustments for small screens */ +@media (max-width: 640px) { + .basil-importance-range { + height: 8px; + } + + .basil-importance-range::-webkit-slider-thumb { + width: 20px; + height: 20px; + } + + .basil-importance-range::-moz-range-thumb { + width: 20px; + height: 20px; + } +} + \ No newline at end of file diff --git a/app/views/basil/help_rate.html.erb b/app/views/basil/help_rate.html.erb index e13407277..90dac8897 100644 --- a/app/views/basil/help_rate.html.erb +++ b/app/views/basil/help_rate.html.erb @@ -1,210 +1,236 @@ - - <% - color_for_rating = { - -2 => 'red lighten-3', - -1 => 'orange lighten-3', - 0 => 'grey lighten-3', - 1 => 'green lighten-3', - 2 => 'blue lighten-3', - 3 => 'red lighten-4', + # Define colors and labels map for reuse + rating_config = { + -2 => { color: 'text-red-500', hover: 'hover:text-red-400', label: 'Very Bad', icon: 'sentiment_very_dissatisfied' }, + -1 => { color: 'text-orange-500', hover: 'hover:text-orange-400', label: 'Bad', icon: 'sentiment_dissatisfied' }, + 0 => { color: 'text-gray-500', hover: 'hover:text-gray-400', label: 'Meh', icon: 'sentiment_neutral' }, + 1 => { color: 'text-yellow-500', hover: 'hover:text-yellow-400', label: 'Good', icon: 'sentiment_satisfied' }, + 2 => { color: 'text-green-500', hover: 'hover:text-green-400', label: 'Great', icon: 'sentiment_very_satisfied' }, + 3 => { color: 'text-pink-500', hover: 'hover:text-pink-400', label: 'Loved', icon: 'favorite' } } + + current_rating_filter = params[:rating].present? ? params[:rating].to_i : nil %> -
    -
    -
    -

    <%= number_with_delimiter @reviewed_commission_count %>

    -
    Images rated
    -
    +
    + +
    + <%= link_to basil_path, class: "inline-flex items-center text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" do %> + chevron_left + Back to Basil + <% end %>
    - <% if params.key?(:rating) %> -
    -
    - Showing <%= pluralize @commissions.count, 'image' %> you rated - "<%= - case params[:rating].to_i - when -2 then 'Very Bad' - when -1 then 'Bad' - when 0 then 'Meh' - when 1 then 'Good' - when 2 then 'Great' - when 3 then 'Loved' - end - -%>". -
    -
    - <% else %> -
    -
    -
    - - You have - <%= 'at least' if @commissions.count >= 50 %> - <%= pluralize @commissions.count, 'generated images' %> - without feedback. - +
    + +
    - <% end %> -
    -
    -
    - <%= link_to basil_rating_queue_path do %> -
    - Unrated images -
    - <% end %> - -
    - Your ratings -
    + +
    +
    +

    Filter by Rating

    +
    +
    - <% end %> + -
    - <%= link_to basil_path do %> - chevron_left - Back to Basil + +
    + <% if current_rating_filter %> + +
    +

    + <%= rating_config[current_rating_filter][:icon] %> + Showing <%= pluralize @commissions.count, 'image' %> you rated + "<%= rating_config[current_rating_filter][:label] %>". +

    +
    + <% else %> + +
    +

    + You have <%= 'at least' if @commissions.count >= 50 %> + <%= pluralize @commissions.count, 'generated image' %> without feedback. +

    +

    + Feedback is optional but helps Basil understand what he does well and what he could improve on. + Below are 50 random images of yours that haven't been rated. You can refresh the page at any time for 50 more. +

    +
    + <%= link_to 'View global stats →', basil_stats_path, class: 'text-orange-300 hover:text-orange-200 font-bold underline' %> +
    +
    <% end %> -
    -
    -
    - - <% if @commissions.empty? %> -
    -

    Hurrah, inbox zero!

    -
    -

    - Your images will appear here when you have any that you haven't rated. -

    -
    - <% end %> - <% @commissions.each do |commission| %> -
    - <% if commission.complete? %> -
    -
    - <%= link_to commission.image do %> - <%= image_tag commission.image %> - <% end %> -
    -
    -
    - <%= link_to commission.entity.name, basil_content_path(content_type: commission.entity_type, id: commission.entity_id) %> - <% if commission.style? %> - (<%= commission.style.humanize %>) + <% if @commissions.empty? %> +
    +
    🎉
    +

    Hurrah, inbox zero!

    +

    + Your images will appear here when you have any that you haven't rated. +

    +
    + <% end %> + +
    + <% @commissions.each do |commission| %> +
    + <% if commission.complete? %> + +
    + <%= link_to commission.image, class: "block" do %> + <%= image_tag commission.image, class: "mx-auto max-h-[500px] object-contain" %> <% end %>
    -
    - Completed <%= time_ago_in_words commission.completed_at %> ago - · - Took <%= distance_of_time_in_words commission.completed_at - commission.created_at %> -
    -
    -
    Feedback for Basil
    -
    - <%= form_for commission.basil_feedbacks.find_or_initialize_by(user: current_user), url: basil_feedback_path(commission.job_id), method: :POST, remote: true do |f| %> - <% f.object.score_adjustment = nil if !f.object.persisted? %> -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    -
    -
    -
    - <% else %> -
    - Basil is still working on this commission... (style: <%= commission.style %>) -
    - (Requested <%= time_ago_in_words(commission.created_at) %> ago) -
    + <% else %> + +
    +
    +
    + palette +
    +
    +

    Basil is still working on this commission...

    +

    Style: <%= commission.style %>

    +

    Requested <%= time_ago_in_words(commission.created_at) %> ago

    +
    +
    +
    + <% end %>
    <% end %>
    - <% end %> - +
    + diff --git a/app/views/basil/index.html.erb b/app/views/basil/index.html.erb index 0b1eb0c00..be457d53f 100644 --- a/app/views/basil/index.html.erb +++ b/app/views/basil/index.html.erb @@ -1,97 +1,157 @@ -
    -
    - <%= image_tag 'basil/portrait.png', style: 'width: 100%' %> -
    -
    -

    Hey, I'm Basil.

    -

    I can help you visualize your characters and other pages.

    -

    - To get started, select the page you want to generate images for. Their description - will be pulled from any answers you've given to relevant fields on their notebook page. -

    -

    - You can also - <%= link_to 'help Basil get better by leaving feedback on your images', basil_rating_queue_path %>. -

    -
    -
    +<% content_for :full_width_page, true %> -
    -
    -
    Draw my...
    - <% @enabled_content_types.each do |content_type| %> - <%= link_to basil_content_index_path(content_type.downcase) do %> -
    - <%= content_class_from_name(content_type).icon %> - <%= content_type.pluralize %> - <% if @content_type == content_type %> - chevron_right +
    + + <%= render 'basil/header', subtitle: "I can help you visualize your characters and world. Select a page on the left to start generating images." %> + +
    + +
    +
    +

    + Draw my... +
    +

    + +
    + <% @enabled_content_types.each do |content_type| %> + <%= link_to basil_content_index_path(content_type.downcase), class: 'block group' do %> +
    +
    +
    +
    + <%= content_class_from_name(content_type).icon %> + <%= content_type.pluralize %> +
    + <% if @content_type == content_type %> +
    + check +
    + <% else %> + chevron_right + <% end %> +
    +
    + <% end %> <% end %>
    - <% end %> - <% end %> - -

    - What about other pages?
    - A lot of work goes into making sure each page produces accurate, high quality images. - More page types will be added soon! -

    -
    -
    - <% unless current_user.on_premium_plan? %> -
    - - Image generation is a Premium-only feature, but free accounts can still generate up - to <%= pluralize BasilService::FREE_IMAGE_LIMIT, 'image' %> for free. - -

    - You have generated <%= pluralize @generated_images_count, 'image' %> - and have <%= pluralize [0, BasilService::FREE_IMAGE_LIMIT - @generated_images_count].max, 'free image' %> remaining: -
    -
    +
    +

    What about other pages?

    +

    + A lot of work goes into making sure each page produces accurate, high quality images. + More page types will be added soon! +

    - - <% if @generated_images_count >= BasilService::FREE_IMAGE_LIMIT %> - <%= link_to 'Click here to manage your billing plan', subscription_path, class: 'blue-text text-darken-4' %> - <% end %>
    - <% end %> +
    - <% if @universe_scope %> -
    - <%= Universe.icon %> - Showing <%= pluralize @content.count, @content_type %> from <%= @universe_scope.name %>. - <%= link_to "Show #{@content_type.pluralize.downcase} from all universes instead.", basil_content_index_path(@content_type, universe: "all"), class: 'purple-text text-lighten-4' %> -
    - <% end %> + +
    + + <% unless current_user.on_premium_plan? %> +
    +
    +
    +
    + star +
    +
    +

    Premium Feature Preview

    +

    + Image generation is a Premium-only feature, but free accounts can still generate up + to <%= pluralize BasilService::FREE_IMAGE_LIMIT, 'image' %> for free. +

    + +
    + Usage + <%= @generated_images_count %> / <%= BasilService::FREE_IMAGE_LIMIT %> +
    + +
    +
    +
    -
    - <% @content.each do |content| %> - <%= link_to basil_content_path(@content_type, content.id) do %> -
    -
    -
    - <%= image_tag content.random_image_including_private(format: :medium), style: 'height: 200px' %> - <%= content.name %> + <% if @generated_images_count >= BasilService::FREE_IMAGE_LIMIT %> + <%= link_to subscription_path, class: 'inline-flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg font-medium transition-colors shadow-sm' do %> + Upgrade to Premium + arrow_forward + <% end %> + <% else %> +

    + You have <%= pluralize [0, BasilService::FREE_IMAGE_LIMIT - @generated_images_count].max, 'free image' %> remaining. +

    + <% end %>
    - <% end %> +
    <% end %> - <%= link_to new_polymorphic_path(@content_type.downcase) do %> -
    -
    -
    - add - New <%= @content_type %> + <% if @universe_scope %> +
    +
    +
    + <%= Universe.icon %> +
    +
    +

    Filtering by Universe

    +

    <%= @universe_scope.name %>

    + <%= link_to basil_content_index_path(@content_type, universe: "all"), class: 'bg-white bg-opacity-10 hover:bg-white bg-opacity-20 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors backdrop-blur-sm flex items-center gap-2' do %> + Show all <%= @content_type.pluralize.downcase %> + close + <% end %>
    <% end %> + +
    + <% @content.each do |content| %> + <%= link_to basil_content_path(@content_type, content.id), class: "group" do %> +
    +
    + <%= image_tag content.random_image_including_private(format: :medium), class: "w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" %> +
    +
    +

    + <%= content.name %> +

    +
    +
    + <% end %> + <% end %> + + <% if current_user.can_create?(@content_type.constantize) %> + <%= link_to new_polymorphic_path(@content_type.downcase), class: "group" do %> +
    +
    + <%= @content_type.constantize.icon rescue 'add' %> +
    + + New <%= @content_type %> + + Create a new page +
    + <% end %> + <% elsif Rails.application.config.content_types[:premium].include?(@content_type.constantize) %> + <% ct = @content_type.constantize %> + <%= link_to subscription_path, class: "group" do %> +
    +
    + <%= ct.icon rescue 'lock' %> +
    + + New <%= @content_type %> + + + Upgrade to Premium to create <%= @content_type.pluralize.downcase %> + +
    + <% end %> + <% end %> +
    +
    - - diff --git a/app/views/basil/review.html.erb b/app/views/basil/review.html.erb index 3d35d5edb..08788e3d3 100644 --- a/app/views/basil/review.html.erb +++ b/app/views/basil/review.html.erb @@ -1,84 +1,215 @@ -
    -
    -
    -
    Most-active creators over 48h
    -
      - <% @commissions_per_user_id.each do |user_id, count| %> - <%# This is an N+1 query, but we can deal with it later %> - <% user = User.find(user_id.to_i) %> -
    1. <%= link_to user.display_name, user %>: <%= pluralize count, 'image' %>
    2. - <% end %> -
    +
    +
    + + +
    + + +
    +

    + Top Creators (48h) +

    +
      + <% @commissions_per_user_id.each do |user_id, count| %> + <% user = @top_users_by_id[user_id.to_i] %> + <% next unless user %> +
    • + <%= link_to user, class: "flex items-center min-w-0" do %> + +
      + <%= user.name[0..1] %> +
      + + <%= user.display_name %> + + <% end %> + + <%= count %> + +
    • + <% end %> +
    +
    + <%= pluralize @unique_users_generating_count.count, 'unique user' %> +
    +
    -
    - <%= pluralize @unique_users_generating_count.count, 'unique user' %> over 48h + +
    +
    +

    + Queue +

    + + <%= @current_queue_items.count %> + +
    + + <% if @current_queue_items.any? %> +
      + <% @current_queue_items.each do |commission| %> +
    • +
      + <%= commission.entity_type %> #<%= commission.entity_id %> +
      +
      + Style: <%= commission.style %> + User: U-<%= commission.user_id %> + #<%= commission.id %> +
      +
    • + <% end %> +
    + <% else %> +
    + Queue is empty +
    + <% end %> +
    +
    -

    -
    Queue
    -
      - <% @current_queue_items.each do |commission| %> -
    1. - <%= commission.entity_type %>-<%= commission.entity_id %> (<%= commission.style %>) - for U-<%= commission.user_id %> (#<%= commission.id %>) -
    2. - <% end %> -
    -
    -
    - <% @recent_commissions.each do |commission| %> -
    - <% if commission.complete? %> -
    -
    - <%= link_to commission.image do %> - <%= image_tag commission.image %> - <% end %> -
    -
    -
    -
    - <%= commission.id %>. - <% if commission.entity.present? %> - <%= content_class_from_name(commission.entity_type).icon %> - <%= link_to commission.entity.name, commission.entity %> - <% end %> - <% if commission.style? %> - (<%= commission.style.humanize %>) - <% end %> - <% if commission.user_id %> - by <%= link_to commission.user.name, commission.user %> + +
    +
    +

    Recent Generations

    +
    + + <% @recent_commissions.each do |commission| %> +
    + <% if commission.complete? %> +
    + +
    + <%= link_to commission.image, class: "block h-full relative" do %> + <% if commission.image.attached? %> + <%= image_tag commission.image, class: "w-full h-full object-cover min-h-[200px]" %> <% else %> - anonymous generation +
    + broken_image +
    <% end %> -
    <%= commission.job_id %>
    +
    + zoom_in +
    + <% end %> +
    + + +
    +
    +
    +
    + <% if commission.entity.present? %> +
    + + + <%= content_class_from_name(commission.entity_type).icon %> + + + <%= link_to commission.entity.name, commission.entity, class: "hover:underline hover:text-blue-600 dark:hover:text-blue-400 text-lg" %> +
    + <% else %> + Deleted Content + <% end %> +
    + #<%= commission.id %> +
    + +
    + <% if commission.style? %> + + <%= commission.style.humanize %> + + <% end %> + + | + + <% if commission.user %> + + by <%= link_to commission.user.name, commission.user, class: "font-medium text-gray-900 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400" %> + + <% else %> + Anonymous + <% end %> +
    + +
    +
    + description + Prompt +
    +
    + <%= commission.prompt %> +
    +
    +
    + +
    +
    + + timer + <%= distance_of_time_in_words(commission.completed_at - commission.created_at) rescue "?" %> + + + fingerprint + <%= commission.job_id.first(8) %>... + +
    + + schedule + <%= time_ago_in_words commission.completed_at %> ago +
    -
      -
    • - Completed <%= time_ago_in_words commission.completed_at %> ago -
    • -
    • - Took <%= distance_of_time_in_words commission.completed_at - commission.created_at %> -
    • -
      -
    • -
      Preprompt:
      - <%= commission.prompt.inspect %> -
    • -
    -
    - <% else %> -
    - Basil is still working on this commission... -
    - (Requested <%= time_ago_in_words(commission.created_at) %> ago) + <% else %> + +
    +
    +
    +
    +
    +
    +
    Generating Loop...
    +
    + Requested <%= time_ago_in_words(commission.created_at) %> ago • Position #<%= @current_queue_items.index(commission) + 1 rescue '?' %> in queue +
    +
    +
    -
    - <% end %> -
    - <% end %> + <% end %> +
    + <% end %> + +
    + <%= will_paginate @recent_commissions, + previous_label: '‹ Previous', + next_label: 'Next ›', + inner_window: 1, + outer_window: 0, + class: 'pagination flex items-center space-x-1', + link_separator: '', + page_links: true %> +
    +
    + + diff --git a/app/views/basil/stats.html.erb b/app/views/basil/stats.html.erb index d6687839b..961fc2b8b 100644 --- a/app/views/basil/stats.html.erb +++ b/app/views/basil/stats.html.erb @@ -1,128 +1,271 @@ -
    -
    - <%= image_tag 'basil/portrait.png', style: 'width: 100%' %> +
    + +
    + <%= link_to basil_path, class: "inline-flex items-center text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors" do %> + chevron_left + Back to Basil + <% end %>
    -
    -

    Hey, I'm Basil.

    -

    I can help you visualize your ideas.

    -

    - - The latest Basil version is v2. This page is currently showing stats for v<%= @version %>. - - <% if @version.to_i != 1 %> - You can see v1's stats by <%= link_to 'clicking here', basil_stats_path(v: 1) %>. - <% end %> - <% if @version.to_i != 2 %> - You can see v2's stats by <%= link_to 'clicking here', basil_stats_path(v: 2) %>. - <% end %> -

    - <%= link_to 'Click here to start generating images of your notebook pages.', basil_path %> -

    -
    -
    -
    + + <%= render 'basil/header', subtitle: "Here's how I've been doing lately.", show_rating_link: false %> -
    -

    <%= number_with_delimiter @queued.count %>

    -
    requests in queue
    -
    -
    -

    <%= time_ago_in_words @average_wait_time.seconds.ago %>

    -
    - average wait time
    today
    -
    -
    -
    -
    - <%= - bar_chart @seconds_over_time, - colors: ['#EF6C00'], - label: 'Images that took this long', - title: 'Wait times today (in minutes)' - %> + +
    + +
    +
    + pending + In Queue +
    +
    + <%= number_with_delimiter @queued.count %> +
    +
    + waiting to generate +
    -

    -
    -
    -

    <%= number_with_delimiter @all_commissions.count %>

    -
    total images created
    + +
    +
    + schedule + Avg Wait +
    +
    + <%= time_ago_in_words @average_wait_time.seconds.ago %> +
    +
    + today's average +
    +
    + + +
    +
    + star + Avg Rating Today +
    + <% if @average_rating_today %> +
    + <%= @average_rating_today %>/5 +
    +
    + from <%= pluralize @total_ratings_today, 'rating' %> +
    + <% else %> +
    + No ratings yet +
    +
    + <%= link_to 'Be the first!', basil_rating_queue_path, class: 'underline hover:text-white' %> +
    + <% end %> +
    -
    - <%= - area_chart @all_commissions.where('created_at > ?', 30.days.ago.beginning_of_month).group_by_day(:created_at).map { |date, count| [date.to_date, count] }, - colors: ['#9C27B0', '#2196F3'], - title: 'Images created per day', - suffix: ' images' - %> + + +
    +
    +
    +
    + flag + Road to 1M Visualizations +
    +
    + <%= number_with_delimiter @all_commissions.count %> / 1,000,000 +
    +
    +
    +
    + + <% progress_percent = [(@all_commissions.count.to_f / 1_000_000 * 100), 100].min %> +
    + +
    +
    + + <% [25, 50, 75].each do |milestone| %> +
    + <% end %> +
    +
    +
    +
    +
    Expected
    +
    <%= (DateTime.current + @days_til_1_million_commissions.days).strftime "%b %e, %Y" %>
    +
    +
    +
    Rate
    +
    <%= @average_commissions_per_day.to_i %>/day
    +
    +
    +
    -
    -
    - At his average rate of <%= pluralize @average_commissions_per_day.to_i, 'image' %> per day, Basil is predicted to generate his 1,000,000th image on - <%= (DateTime.current + @days_til_1_million_commissions.days).strftime "%A, %B %e, %Y" %>. + +
    + +
    +
    +

    + schedule + Wait Times Today +

    +
    +
    + <%= bar_chart @seconds_over_time, + colors: ['#f97316'], + label: 'Images that took this long', + library: { chart: { backgroundColor: 'transparent' } } + %> +
    +
    + + +
    +
    +

    + show_chart + Images Created (Last 30 Days) +

    +
    +
    + <%= area_chart @all_commissions.where('created_at > ?', 30.days.ago.beginning_of_month).group_by_day(:created_at).map { |date, count| [date.to_date, count] }, + colors: ['#a855f7'], + suffix: ' images', + library: { chart: { backgroundColor: 'transparent' } } + %> +
    -


    -
    - <%= - column_chart [ - { name: "Historical average", data: @emoji_counts_all_time }, - { name: "Today", data: @emoji_counts_today, } - ], - colors: ['#2196F3', '#C88ED2'], - title: "Quality: #{number_with_delimiter @feedback_today.values.sum + @feedback_before_today.values.sum} #{'image'.pluralize @feedback_before_today.values.sum} rated", - suffix: '%' - %> + +
    +
    +

    + star + Quality Ratings +

    + <%= link_to basil_rating_queue_path, class: "inline-flex items-center px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition-colors" do %> + thumb_up + Help rate images + <% end %> +
    + +
    + +
    +
    +

    + Historical vs Today +

    +

    + <%= number_with_delimiter @feedback_today.values.sum + @feedback_before_today.values.sum %> images rated +

    +
    +
    + <%= column_chart [ + { name: "Historical average", data: @emoji_counts_all_time }, + { name: "Today", data: @emoji_counts_today } + ], + colors: ['#3b82f6', '#c084fc'], + suffix: '%', + library: { chart: { backgroundColor: 'transparent' } } + %> +
    +
    + + +
    +
    +

    + Today's Breakdown +

    +
    +
    + <%= pie_chart @emoji_counts_today, + colors: ['#b91c1c', '#ea580c', '#6b7280', '#22c55e', '#16a34a', '#ec4899'], + legend: 'bottom', + donut: true, + suffix: '%', + library: { chart: { backgroundColor: 'transparent' } } + %> +
    +
    +
    + + +
    + +
    +
    +

    + palette + Average Score by Style +

    +
    +
    + <%= bar_chart @average_score_per_style, + min: -2, + max: 2, + colors: ['#22c55e'], + library: { chart: { backgroundColor: 'transparent' } } + %> +
    +
    + + +
    +
    +

    + leaderboard + Total Score by Style +

    +
    +
    + <%= bar_chart @total_score_per_style, + colors: ['#22c55e'], + library: { chart: { backgroundColor: 'transparent' } } + %> +
    +
    +
    -
    -
    - <%= - pie_chart @emoji_counts_today, - colors: ['#B71C1C', '#D95151', '#757575' '#1B5E20', '#236428', '#43A047', '#2196F3'], - legend: 'right', - donut: true, - title: "Today's quality ratings", - suffix: '%' + + +
    +
    +

    + category + Average Quality by Content Type +

    +

    + How well Basil performs for different page types +

    +
    +
    + <%= column_chart @average_score_per_page_type, + colors: ['#3b82f6'], + min: -3, + max: 3, + library: { chart: { backgroundColor: 'transparent' } } %>
    -


    -
    - <%= - bar_chart @average_score_per_style, - title: "Average quality score per style", - min: -2, - max: 2, - colors: ['#2196F3'] - %> -
    -
    - <%= - bar_chart @total_score_per_style, - title: "Total quality score per style", - colors: ['#2196F3'] - %> -
    -
    -
    -
    - <%= link_to 'Click here to help by rating your images.', basil_rating_queue_path %> + +
    +
    + Viewing v<%= @version %> stats + +
    + Other versions: + <% [1, 2].reject { |v| v == @version.to_i }.each do |v| %> + <%= link_to "v#{v}", basil_stats_path(v: v), class: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 hover:underline" %> + <% end %> +
    - -
    -
    - <%= - column_chart @average_score_per_page_type, - title: "Average quality score per page type", - colors: ['#2196F3'], - min: -3, - max: 3 - %> -
    -
    \ No newline at end of file diff --git a/app/views/books/_book_card.html.erb b/app/views/books/_book_card.html.erb new file mode 100644 index 000000000..0d115e002 --- /dev/null +++ b/app/views/books/_book_card.html.erb @@ -0,0 +1,69 @@ +<% + status_colors = { + 'writing' => 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + 'revising' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + 'submitting' => 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + 'published' => 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' + } + status_color = status_colors[book.status] || status_colors['writing'] +%> + +
    '.includes(searchQuery.toLowerCase())" + x-data="{ isFavorite: <%= book.favorite? %>, isLoading: false }" +> + <%= link_to edit_book_path(book), class: "block" do %> +
    + +
    + <% + # custom_thumbnail_url returns nil if no images are uploaded, letting us use the view's asset_path natively for the fallback + cover_image = book.custom_thumbnail_url(format: :medium) || asset_path('card-headers/books.webp') + %> + <%= image_tag cover_image, class: 'absolute inset-0 w-full h-full object-cover' %> + + +
    + + + + +
    + + <%= book.status.humanize %> + +
    +
    + +
    +

    + <%= book.name %> +

    + <% if book.subtitle.present? %> +

    + <%= book.subtitle %> +

    + <% end %> + +
    +
    + description + <%= pluralize book.documents.count, 'document' %> +
    +
    + Updated <%= time_ago_in_words(book.updated_at) %> ago +
    +
    +
    +
    + <% end %> +
    diff --git a/app/views/books/_book_document_row.html.erb b/app/views/books/_book_document_row.html.erb new file mode 100644 index 000000000..9e5d7f4bc --- /dev/null +++ b/app/views/books/_book_document_row.html.erb @@ -0,0 +1,41 @@ +
  • + +
    + drag_indicator +
    + + +
    + <%= index + 1 %> +
    + + +
    + <%= link_to edit_document_path(book_document.document), class: "block group-hover:text-emerald-600 dark:group-hover:text-emerald-400" do %> +

    + <%= book_document.document.title.presence || 'Untitled' %> +

    +

    + <%= number_with_delimiter(book_document.document.word_count) %> <%= 'word'.pluralize(book_document.document.word_count) %> + <% if book_document.document.updated_at %> + • Updated <%= time_ago_in_words(book_document.document.updated_at) %> ago + <% end %> +

    + <% end %> +
    + + +
    + <%= link_to edit_document_path(book_document.document), class: "p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg", title: "Edit document" do %> + edit + <% end %> + + <%= button_to remove_document_book_path(book_document.book, document_id: book_document.document_id), method: :delete, remote: true, class: "p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900 rounded-lg", title: "Remove from book", data: { confirm: "Remove this document from the book?" } do %> + close + <% end %> +
    +
  • diff --git a/app/views/books/_new_document_modal.html.erb b/app/views/books/_new_document_modal.html.erb new file mode 100644 index 000000000..22b0fa0af --- /dev/null +++ b/app/views/books/_new_document_modal.html.erb @@ -0,0 +1,60 @@ +
    +
    +
    + + +
    +
    +
    +
    + description +
    +
    +

    New Document

    +
    + + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    diff --git a/app/views/books/_settings_modal.html.erb b/app/views/books/_settings_modal.html.erb new file mode 100644 index 000000000..64d334465 --- /dev/null +++ b/app/views/books/_settings_modal.html.erb @@ -0,0 +1,251 @@ + diff --git a/app/views/books/_settings_sidebar.html.erb b/app/views/books/_settings_sidebar.html.erb new file mode 100644 index 000000000..b57d471e2 --- /dev/null +++ b/app/views/books/_settings_sidebar.html.erb @@ -0,0 +1,185 @@ + + diff --git a/app/views/books/_sidebar_book_section.html.erb b/app/views/books/_sidebar_book_section.html.erb new file mode 100644 index 000000000..2220c522d --- /dev/null +++ b/app/views/books/_sidebar_book_section.html.erb @@ -0,0 +1,104 @@ +<%# Collapsible book section for the documents sidebar %> +<%# Requires: book, document (current document being edited) %> +<%# Alpine.js context: expandedBooks array, isExpanded(bookId), toggleBook(bookId) %> + +
    + + + + +
    + <% if book.book_documents.any? %> +
      + <% book.book_documents.order(position: :asc).each_with_index do |book_document, index| %> + <% is_current = book_document.document_id == document.id %> +
    • + +
      + drag_indicator +
      + + + <% if is_current %> + + <%= index + 1 %> + + <% else %> + + <%= index + 1 %> + + <% end %> + + + <% if is_current %> + + <%= book_document.document.title.presence || 'Untitled' %> + edit + + <% else %> + <%= link_to edit_document_path(book_document.document), + class: "flex-1 text-sm text-gray-700 dark:text-gray-200 hover:text-notebook-blue dark:hover:text-blue-400 truncate transition-colors" do %> + <%= book_document.document.title.presence || 'Untitled' %> + <% end %> + <% end %> + + + + <%= number_with_delimiter(book_document.document.cached_word_count || 0) %>w + +
    • + <% end %> +
    + <% else %> +
    +

    No chapters yet

    +
    + <% end %> + + +
    + <%= link_to edit_book_path(book), class: "flex items-center px-3 py-2 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" do %> + edit + Edit book + <% end %> + +
    + + +
    + <%= link_to create_document_book_path(book), method: :post, class: "w-full flex items-center justify-center px-3 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-md transition-colors text-sm font-medium" do %> + add + Add Document + <% end %> +
    +
    +
    diff --git a/app/views/books/_toc_content.html.erb b/app/views/books/_toc_content.html.erb new file mode 100644 index 000000000..6fe72c2dd --- /dev/null +++ b/app/views/books/_toc_content.html.erb @@ -0,0 +1,95 @@ +
    +

    Table of Contents

    +

    Drag to reorder documents in your book

    +
    + + <% if book_documents.any? %> +
      + <% book_documents.each_with_index do |book_document, index| %> + <%= render 'book_document_row', book_document: book_document, index: index %> + <% end %> +
    + <% else %> +
    +
    + description +
    +

    No documents yet

    +

    Add documents to build your table of contents

    +
    + <% end %> + + +
    +
    + + +
    + +
    +
    +

    Select a document to add

    + +
    + + +
    +
    + search +
    + +
    + + +
    + <% documents_not_in_book = available_documents.reject { |d| book.document_ids.include?(d.id) } %> + <% if documents_not_in_book.any? %> + <% documents_not_in_book.each do |document| %> +
    '.includes(searchQuery.toLowerCase())" + class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0" + > +
    + description +
    +

    <%= document.title.presence || 'Untitled' %>

    +

    <%= number_with_delimiter(document.word_count) %> <%= 'word'.pluralize(document.word_count) %>

    +
    +
    + <%= button_to add_document_book_path(book, document_id: document.id), method: :post, remote: true, class: "flex-shrink-0 inline-flex items-center px-3 py-1.5 text-xs font-medium text-white #{Book.color} rounded-lg hover:opacity-90" do %> + add + Add + <% end %> +
    + <% end %> + <% else %> +
    +

    All documents are already in this book

    + <%= link_to new_document_path, class: "text-sm #{Book.text_color} hover:underline mt-2 inline-block" do %> + Create a new document + <% end %> +
    + <% end %> +
    +
    +
    diff --git a/app/views/books/add_document.js.erb b/app/views/books/add_document.js.erb new file mode 100644 index 000000000..ad2db4954 --- /dev/null +++ b/app/views/books/add_document.js.erb @@ -0,0 +1,5 @@ +document.getElementById('book-toc-content').innerHTML = "<%= j render(partial: 'books/toc_content', locals: { book: @book, book_documents: @book_documents, available_documents: @available_documents }) %>"; + +if (typeof window.initBookSortable === 'function') { + window.initBookSortable(); +} diff --git a/app/views/books/edit.html.erb b/app/views/books/edit.html.erb new file mode 100644 index 000000000..a6ff40302 --- /dev/null +++ b/app/views/books/edit.html.erb @@ -0,0 +1,482 @@ +<% + status_options = [ + ['Writing', 'writing'], + ['Revising', 'revising'], + ['Submitting', 'submitting'], + ['Published', 'published'] + ] +%> + +
    + + +
    +
    +
    + <%= link_to books_path, class: "text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 mr-2 lg:mr-0 inline-flex items-center" do %> + arrow_back + <% end %> +
    +
    + <%= Book.icon %> +
    +
    +

    + <%= link_to @book.name, @book, class: "hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors" %> +

    +

    + <%= @book.status.humanize %> • <%= pluralize @book.documents.count, 'document' %> +

    +
    +
    +
    + + +
    + <%= link_to @book, class: "bg-gray-600 border border-gray-600 rounded-md shadow-sm px-3 py-1.5 flex items-center text-sm font-medium text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-colors" do %> + visibility + + <% end %> + + +
    + + +
    +
    +
    +
    + + + + + +
    + +
    +
    + +
    +
    + +
    +
    + +
    + +
    + <%= form_with model: @book, local: false, html: { multipart: true } do |f| %> +
    + +
    + + <%= f.text_field :name, class: "block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white js-autosave" %> +
    + + +
    + + <%= f.text_field :subtitle, class: "block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white js-autosave", placeholder: "Optional subtitle" %> +
    + + +
    + + <%= f.select :status, options_for_select(status_options, @book.status), {}, class: "block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white js-autosave" %> +
    + + +
    + + <%= f.text_area :description, rows: 5, class: "block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none js-autosave", placeholder: "What's this book about?" %> +
    + + +
    + + <%= f.text_area :blurb, rows: 5, class: "block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none js-autosave", placeholder: "The hook for readers..." %> +
    + + +
    + + <%= f.collection_select :universe_id, current_user.universes.order(:name), :id, :name, { include_blank: 'No universe' }, class: "block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white js-autosave" %> +
    +
    + <% end %> +
    + + +
    +
    + <%= render partial: 'books/toc_content', locals: { book: @book, book_documents: @book_documents, available_documents: @available_documents } %> +
    + +
    + + +
    + <%= form_for @book, url: book_path(@book), method: :patch, html: { multipart: true } do |f| %> +
    + <%= render partial: 'content/edit/gallery_panel', locals: { raw_content: @book, content: @book, f: f } %> +
    + <% end %> +
    +
    +
    +
    + + + + + <% + # Calculate analytics stats + total_words = @book_documents.sum { |bd| bd.document&.word_count.to_i } + doc_count = @book_documents.count + avg_words = doc_count > 0 ? (total_words / doc_count) : 0 + est_pages = (total_words / 250.0).ceil + reading_mins = (total_words / 200.0).ceil + + # Find longest/shortest documents + docs_with_counts = @book_documents.map do |bd| + { title: bd.document&.title.presence || 'Untitled', words: bd.document&.word_count.to_i } + end + longest_doc = docs_with_counts.max_by { |d| d[:words] } + shortest_doc = docs_with_counts.min_by { |d| d[:words] } + max_words = docs_with_counts.map { |d| d[:words] }.max || 1 + + # Novel length category + length_category = case total_words + when 0..999 then 'Flash Fiction' + when 1000..7499 then 'Short Story' + when 7500..17499 then 'Novelette' + when 17500..39999 then 'Novella' + when 40000..99999 then 'Novel' + else 'Epic Novel' + end + + # Last updated + last_updated = @book_documents.map { |bd| bd.document&.updated_at }.compact.max + %> + + + + + <%= render 'settings_sidebar' %> + +
    + + <%= render 'new_document_modal' %> +
    + + + diff --git a/app/views/books/index.html.erb b/app/views/books/index.html.erb new file mode 100644 index 000000000..88efc07b0 --- /dev/null +++ b/app/views/books/index.html.erb @@ -0,0 +1,261 @@ +
    + + +
    +
    + <%= image_tag asset_path("card-headers/books.webp"), class: 'h-32 w-full object-cover lg:h-48' %> +
    + +
    +
    +
    +
    +
    + <%= Book.icon %> +
    +
    + +
    +

    + Your Books +

    +

    + <%= pluralize @books.count, 'book' %> + <% if @universe_scope %> + in <%= link_to @universe_scope.name, @universe_scope, class: "#{Universe.text_color} font-medium hover:underline" %> + <% end %> +

    +
    +
    + +
    + <%= link_to new_book_path do %> + + <% end %> +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + search +
    + +
    + +
    +
    + + +
    + + + +
    + + + +
    + +
    + + +
    +
    +
    + + +
    + <% if @books.any? %> + +
    + <% @books.each do |book| %> +
    '.includes(searchQuery.toLowerCase())) && matchesFilters('<%= book.status %>', <%= book.favorite? %>)"> + <%= render 'book_card', book: book %> +
    + <% end %> +
    + + + + <% else %> + +
    +
    +
    + <%= Book.icon %> +
    +

    Create your first book

    +

    + Books help you organize your documents into something bigger. Create a book to start building your table of contents. +

    + <%= link_to new_book_path, class: "inline-flex items-center px-6 py-3 text-base font-medium rounded-lg text-white #{Book.color} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 shadow-sm" do %> + add + Create a Book + <% end %> +
    +
    + <% end %> + + <% if @universe_scope.present? %> +
    + <%= render 'shared/universe_filter_reminder', content_type_name: 'Book' %> +
    + <% end %> +
    +
    + + diff --git a/app/views/books/remove_document.js.erb b/app/views/books/remove_document.js.erb new file mode 100644 index 000000000..ad2db4954 --- /dev/null +++ b/app/views/books/remove_document.js.erb @@ -0,0 +1,5 @@ +document.getElementById('book-toc-content').innerHTML = "<%= j render(partial: 'books/toc_content', locals: { book: @book, book_documents: @book_documents, available_documents: @available_documents }) %>"; + +if (typeof window.initBookSortable === 'function') { + window.initBookSortable(); +} diff --git a/app/views/books/show.html.erb b/app/views/books/show.html.erb new file mode 100644 index 000000000..c2b31cd5b --- /dev/null +++ b/app/views/books/show.html.erb @@ -0,0 +1,223 @@ +<% + set_meta_tags title: @book.name, + description: @book.blurb.presence || @book.description.presence || "A book by #{@book.user.display_name}", + image_src: image_url(@book.first_public_image), + og: { type: 'book' }, + twitter: { card: 'photo', image: image_url(@book.first_public_image) } + + valid_cover = @book.image_uploads.find { |upload| upload.src_file_name.present? } +%> + +
    + + +
    + <% if valid_cover.present? %> + <%= image_tag valid_cover.src(:large), + class: "w-full h-full object-cover", + alt: "#{@book.name} banner image" %> + <% else %> + <%= image_tag "card-headers/books.webp", + class: "w-full h-full object-cover opacity-70", + alt: "#{@book.name} banner image" %> + <% end %> + + +
    + + +
    +
    +
    +
    + +
    + + + + +
    +
    +

    + <%= @book.name %> +

    +
    + + <% if @book.subtitle.present? %> +
    <%= @book.subtitle %>
    + <% end %> +
    +
    + + + <%= link_to user_path(@book.user), class: "flex-shrink-0 flex items-center space-x-3 bg-white bg-opacity-10 hover:bg-opacity-20 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 ease-out shadow-sm hover:shadow-md rounded-full pl-2 pr-4 py-1.5 border border-white border-opacity-10 hover:border-opacity-30 group" do %> + <% if @book.user.image_url.present? %> + <%= image_tag @book.user.image_url, class: "w-6 h-6 rounded-full ring-2 ring-white ring-opacity-20 group-hover:ring-opacity-40 transition-all" %> + <% else %> +
    <%= @book.user.display_name.first %>
    + <% end %> + by <%= @book.user.display_name %> + <% end %> + +
    +
    +
    +
    +
    + + +
    +
    + + +
    + + + <% if @book.description.present? || @book.blurb.present? %> +
    + <% if @book.blurb.present? %> +
    +
    + <%= simple_format @book.blurb %> +
    +
    + <% end %> + + <% if @book.description.present? %> +
    +

    About the Book

    +
    + <%= simple_format @book.description %> +
    +
    + <% end %> +
    + <% end %> + + +
    +
    +

    + format_list_numbered + Table of Contents +

    + + <%= pluralize(@book_documents.count, 'Chapter') %> + +
    + +
    + <% if @book_documents.any? %> + <% @book_documents.each_with_index do |bd, idx| %> + <%= link_to document_path(bd.document), class: "group block hover:bg-gray-50 dark:hover:bg-gray-700/50 transition duration-150 ease-in-out" do %> +
    +
    + + <%= (idx + 1).to_s.rjust(2, '0') %>. + +

    + <%= bd.document.title.presence || 'Untitled Chapter' %> +

    +
    +
    + + + timer + <%= ((bd.document.word_count || 0) / 200.0).ceil %> min + + chevron_right +
    +
    + <% end %> + <% end %> + <% else %> +
    + auto_stories +

    No chapters have been published yet.

    +
    + <% end %> +
    +
    +
    + + +
    + + <% if @book.updatable_by?(current_user) %> +
    +

    + edit Author Controls +

    +

    + <% if @book.privacy == 'public' %> + You are currently viewing the public layout. This is what readers will see. + <% else %> + You are currently viewing the reader layout. This book is private, so only you and collaborators can see it. + <% end %> +

    + <%= link_to edit_book_path(@book), class: "w-full flex justify-center items-center px-4 py-2 text-sm font-medium rounded-lg text-blue-700 bg-blue-100 hover:bg-blue-200 dark:text-blue-200 dark:bg-blue-800 dark:hover:bg-blue-700 transition duration-150 ease-in-out" do %> + Return to Editing + <% end %> +
    + <% end %> + +
    +
    +

    Book Details

    +
    +
    +
    + <% if @universe.present? %> +
    +
    Universe
    +
    + <%= link_to @universe.name, universe_path(@universe), class: "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" %> +
    +
    + <% end %> +
    +
    Word Count
    +
    <%= number_with_delimiter(@total_words) %>
    +
    +
    +
    Reading Time
    +
    <%= @est_reading_time %> minutes
    +
    +
    +
    Last Updated
    +
    <%= time_ago_in_words(@book.updated_at) %> ago
    +
    +
    +
    +
    + +
    + +
    +
    + +
    + + +
    + + + <%= render 'shared/share_to_stream_modal', + content: @book, + content_type: @book.class.name, + content_id: @book.id, + content_name: @book.name, + content_owner: @book.user %> + +
    diff --git a/app/views/browse/tag.html.erb b/app/views/browse/tag.html.erb index 062263f71..607966667 100644 --- a/app/views/browse/tag.html.erb +++ b/app/views/browse/tag.html.erb @@ -1,2063 +1,406 @@ - - +<% + # Calculate stats + total_items = @tagged_content.sum { |group| group[:content].count } + creators_count = @tagged_content.flat_map { |group| group[:content].map(&:user_id) }.uniq.count -
    - -
    -
    - lock_open - All content on Notebook.ai is private by default. Only pages with the ArtFight2025 tag and set to public appear here. + # Flatten all content into a single array for unified masonry grid + all_content = @tagged_content.flat_map do |group| + group[:content].map do |item| + { + item: item, + type: group[:type], + icon: group[:icon], + color: group[:color] + } + end + end.sort_by { |c| c[:item].updated_at }.reverse +%> + + + +
    +
    + +
    + brush +
    +

    + info + How to join this showcase +

    -
    - -
    - <% - # Generate a consistent pattern for the background based on tag name - pattern_seed = 5 # Fixed for ArtFight tag - pattern_url = asset_path("card-headers/patterns/pattern#{pattern_seed}.png") +
    +

    Want your page to appear here? Follow these steps:

    - # Calculate total items for the stats display - total_items = @tagged_content.sum { |group| group[:content].count } - content_types_count = @tagged_content.size - creators_count = @tagged_content.flat_map { |group| group[:content].map(&:user_id) }.uniq.count - %> - - -
    -
    - - - - - - - -
    + + +
    + +
    +
    +
    + <%= PageTag.icon %> + Community Tag Showcase +
    +

    + <%= @tag.presence || "ArtFight 2025" %> +

    +

    + Browse characters, locations, and creative content from Notebook.ai creators participating in Art Fight. Find inspiration for your next art attack! +

    +
    + + +
    +
    +
    <%= number_with_delimiter(total_items) %>
    +
    Pages
    +
    + +
    +
    <%= number_with_delimiter(creators_count) %>
    +
    Creators
    +
    +
    +
    +
    + +
    +
    + + +
    + + <% @tagged_content.each do |content_group| %> + + <% end %> +
    + + +
    + + +
    +
    + sort
    - - -
    -
    - -

    Sort by:

    -
    - -
    - -
    + +
    + expand_more
    - + + +
    - - -
    - <% if @tagged_content.empty? %> -
    -
    No public content with this tag
    -

    - No one has created any public content with the "<%= @tag %>" tag yet. -

    -
    - <% else %> - <% @tagged_content.each do |content_group| %> -
    -
    -
    - -

    - <%= content_group[:type].pluralize %> - (<%= content_group[:content].count %>) -

    -
    - -
    - - Showing <%= content_group[:content].count %> items - -
    -
    - - <% if content_group[:type].downcase == 'universe' %> - -
    - <% content_group[:content].each_with_index do |content, index| %> -
    - <%= link_to content, - class: "content-card-link", - aria: { - label: "#{content.respond_to?(:name) ? content.name : content.title} (#{content.class.name})", - describedby: "card-desc-#{content.class.name}-#{content.id}" - }, - tabindex: "0" do %> -
    - -
    ;">
    - -
    - <% - # Find image for this content - content_image = @random_image_pool_cache.fetch([content.class.name, content.id], []) - .sample - .try(:src, :medium) +
    + +
    - if @saved_basil_commissions - content_image ||= @saved_basil_commissions.fetch([content.class.name, content.id], []) - .sample - .try(:image) - .try(:url) - end + +<% if all_content.empty? %> +
    +
    + + brush + palette + style +
    +

    Be the first to join in!

    +

    + No one has created any public content with the "<%= @tag %>" tag yet. This is your chance to showcase your incredible creations to the entire community! +

    + +
    +<% else %> +
    + <% all_content.each do |content_data| %> + <% + content = content_data[:item] + content_type = content_data[:type] + content_icon = content_data[:icon] - content_image ||= asset_path("card-headers/#{content.class.name.downcase.pluralize}.jpg") - %> - -
    - - -
    -
    -
    - - -
    -
    - <% content_name = content.respond_to?(:name) ? content.name : content.title %> - <%= ContentFormatterService.show(text: content_name.presence || 'Untitled', viewing_user: current_user) %> -
    - -
    ; color: white; border-radius: 4px; padding: 2px 6px; font-size: 10px; font-weight: 500; backdrop-filter: blur(4px); z-index: 3; box-shadow: 0 1px 3px rgba(0,0,0,0.2);"> - <%= content_group[:icon] %> - <%= content.class.name %> -
    -
    -
    - - -
    - - <% user = @users_cache[content.user_id] %> - <% if user %> -
    -
    -
    - <% if user.image_url %> - <%= user.display_name %> - <% else %> - person - <% end %> -
    - <%= user.display_name %> -
    - - - update - <%= time_ago_in_words content.updated_at %> ago - -
    - <% end %> - - - <% if content.respond_to?(:page_tags) && content.page_tags.any? %> -
    - <% content.page_tags.each do |tag| %> - <% if tag.tag == @tag %> - ; color: white; border-radius: 20px; font-size: 11px; font-weight: 500; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"> - <%= tag.tag %> - - <% else %> - - <%= tag.tag %> - - <% end %> - <% end %> -
    - <% end %> -
    -
    - <% end %> -
    - <% end %> -
    - <% else %> - -
    - <% content_group[:content].each_with_index do |content, index| %> -
    - <%= link_to content, - target: (content.is_a?(Document) ? '_blank' : nil), - class: "content-card-link", - aria: { - label: "#{content.respond_to?(:name) ? content.name : content.title} (#{content.class.name})", - describedby: "card-desc-#{content.class.name}-#{content.id}" - }, - tabindex: "0" do %> -
    - -
    ;">
    - -
    - <% - # Find image for this content - content_image = @random_image_pool_cache.fetch([content.class.name, content.id], []) - .sample - .try(:src, :medium) + # Get content name + content_name = content.respond_to?(:name) ? content.name : content.title + content_name = content_name.presence || 'Untitled' - if @saved_basil_commissions - content_image ||= @saved_basil_commissions.fetch([content.class.name, content.id], []) - .sample - .try(:image) - .try(:url) - end + # Find image for this content + content_image = @random_image_pool_cache.fetch([content.class.name, content.id], []) + .sample + .try(:src, :medium) - content_image ||= asset_path("card-headers/#{content.class.name.downcase.pluralize}.jpg") - %> - -
    - - -
    -
    -
    - - -
    -
    - <% content_name = content.respond_to?(:name) ? content.name : content.title %> - <%= ContentFormatterService.show(text: content_name.presence || 'Untitled', viewing_user: current_user) %> -
    - -
    ; color: white; border-radius: 4px; padding: 2px 6px; font-size: 10px; font-weight: 500; backdrop-filter: blur(4px); z-index: 3; box-shadow: 0 1px 3px rgba(0,0,0,0.2);"> - <%= content_group[:icon] %> - <%= content.class.name %> -
    -
    -
    - - -
    - - <% user = @users_cache[content.user_id] %> - <% if user %> -
    -
    -
    - <% if user.image_url %> - <%= user.display_name %> - <% else %> - person - <% end %> -
    - <%= user.display_name %> -
    - - - update - <%= time_ago_in_words content.updated_at %> ago - -
    - <% end %> - - - <% if content.respond_to?(:page_tags) && content.page_tags.any? %> -
    - <% content.page_tags.each do |tag| %> - <% if tag.tag == @tag %> - ; color: white; border-radius: 20px; font-size: 11px; font-weight: 500; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"> - <%= tag.tag %> - - <% else %> - - <%= tag.tag %> - - <% end %> - <% end %> -
    - <% end %> -
    -
    - <% end %> -
    - <% end %> -
    - <% end %> -
    - <% end %> + if @saved_basil_commissions + content_image ||= @saved_basil_commissions.fetch([content.class.name, content.id], []) + .sample + .try(:image) + .try(:url) + end + + content_image ||= asset_path("card-headers/#{content.class.name.downcase.pluralize}.webp") + + # Get user + user = @users_cache[content.user_id] + %> + +
    + + <%= link_to content, + target: (content.is_a?(Document) ? '_blank' : nil), + class: "block relative overflow-hidden rounded-2xl bg-gray-100 dark:bg-gray-800 shadow-sm transition-all duration-300 ease-out hover:shadow-xl hover:scale-[1.02] hover:-translate-y-1 focus:outline-none focus:ring-4 focus:ring-purple-500 ring-opacity-50" do %> + + +
    + <%= content_name %> +
    + + +
    + <%= content_icon %> +
    + + +
    +

    + <%= ContentFormatterService.show(text: content_name, viewing_user: current_user) %> +

    + + <% if user %> +
    + <% if user.image_url.present? %> + <%= user.display_name %> + <% else %> +
    + person +
    + <% end %> + <%= user.display_name %> +
    + <% end %> +
    + <% end %> +
    <% end %>
    +<% end %>
    - - -<%= content_for :javascript do %> - // Handle card image loading with skeleton effect - document.addEventListener('DOMContentLoaded', function() { - // Initialize image lazy loading with skeleton effect - initLazyLoadingWithSkeletons(); - - // Initialize keyboard navigation for filter buttons - initKeyboardNavigationForFilters(); - - // Initialize mobile filter FAB behavior - initMobileFilterFAB(); - - // Initialize number counters for statistics - initNumberCounters(); - - // Add animation classes to elements - document.querySelectorAll('.js-content-card-container, .universe-card-container').forEach(function(card, index) { - // Add data attributes for sorting - const timeElement = card.querySelector('.time-info'); - if (timeElement) { - const timeText = timeElement.textContent.trim(); - let updatedAt = new Date(); - - // Parse "X time ago" format roughly - if (timeText.includes('minute')) { - const minutes = parseInt(timeText.match(/\d+/)[0] || 0); - updatedAt.setMinutes(updatedAt.getMinutes() - minutes); - } else if (timeText.includes('hour')) { - const hours = parseInt(timeText.match(/\d+/)[0] || 0); - updatedAt.setHours(updatedAt.getHours() - hours); - } else if (timeText.includes('day')) { - const days = parseInt(timeText.match(/\d+/)[0] || 0); - updatedAt.setDate(updatedAt.getDate() - days); - } else if (timeText.includes('month')) { - const months = parseInt(timeText.match(/\d+/)[0] || 0); - updatedAt.setMonth(updatedAt.getMonth() - months); - } else if (timeText.includes('year')) { - const years = parseInt(timeText.match(/\d+/)[0] || 0); - updatedAt.setFullYear(updatedAt.getFullYear() - years); - } - - card.setAttribute('data-updated-at', updatedAt.toISOString()); - } - - // Stagger animation delay for cards - card.style.opacity = '0'; - card.style.transform = 'translateY(20px)'; - card.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; - - // Stagger entrance animations - setTimeout(function() { - card.style.opacity = '1'; - card.style.transform = 'translateY(0)'; - }, 50 + (index * 30)); // 30ms delay between each card's animation - }); - - // Handle parallax effect on header - initHeaderParallax(); - - // Handle focus trapping in visible content sections - setupFocusManagement(); - - }); - - // Initialize number counters with animation - function initNumberCounters() { - const counters = document.querySelectorAll('.counter-value'); - - counters.forEach(counter => { - const finalCount = parseInt(counter.getAttribute('data-count'), 10); - const originalText = counter.textContent; - const suffix = originalText.replace(/^\d+\s*/, ''); // Extract text after the number - - // Only animate if it's a reasonable number to count up to - if (finalCount <= 300) { - let count = 0; - const duration = 1500; // ms - const interval = Math.max(10, duration / finalCount); - - setTimeout(() => { - const timer = setInterval(() => { - count++; - counter.textContent = count + ' ' + suffix; - - if (count >= finalCount) { - counter.textContent = originalText; // Reset to original for correct pluralization - clearInterval(timer); - } - }, interval); - }, 500); // Delay start slightly to ensure elements are visible - } - }); - } - - // Initialize header parallax effect - function initHeaderParallax() { - const header = document.querySelector('.artfight-header'); - const background = document.querySelector('.animated-background'); - - if (header && background) { - window.addEventListener('scroll', function() { - const scrollPosition = window.scrollY; - if (scrollPosition < 500) { // Only apply effect near the top of the page - const translateY = scrollPosition * 0.15; - background.style.transform = `scale(1.1) translateY(${translateY}px)`; - } - }); - } - } - - // Initialize mobile filter FAB behavior - function initMobileFilterFAB() { - const filterSection = document.querySelector('.category-filters'); - const filterFab = document.getElementById('mobile-filter-fab'); - const isMobile = window.innerWidth <= 768; - - if (filterSection && filterFab && isMobile) { - // Show FAB when filter section is out of view - window.addEventListener('scroll', function() { - const filterRect = filterSection.getBoundingClientRect(); - - // If filter section is above viewport, show the FAB - if (filterRect.bottom < 0) { - filterFab.style.display = 'block'; - - // Animate in - filterFab.style.transform = 'scale(1)'; - filterFab.style.opacity = '1'; - } else { - filterFab.style.transform = 'scale(0)'; - filterFab.style.opacity = '0'; - - // Hide after animation completes - setTimeout(function() { - if (filterRect.bottom >= 0) { - filterFab.style.display = 'none'; - } - }, 300); - } - }); - } - } - - // Function to scroll back to filters when FAB is clicked - function showMobileFilters() { - const filterSection = document.querySelector('.category-filters'); - if (filterSection) { - // Scroll the filter section into view with smooth animation - filterSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - - // Initialize keyboard navigation for filter buttons - function initKeyboardNavigationForFilters() { - const filterButtonsContainer = document.querySelector('.filter-buttons-container'); - const filterButtons = Array.from(document.querySelectorAll('.category-filter')); - - if (filterButtonsContainer && filterButtons.length > 0) { - // Add keyboard navigation for tab list - filterButtonsContainer.addEventListener('keydown', function(e) { - // Find index of the current focused button - const focusedIndex = filterButtons.indexOf(document.activeElement); - - if (focusedIndex >= 0) { - let nextIndex; - - switch (e.key) { - case 'ArrowRight': - case 'ArrowDown': - e.preventDefault(); - nextIndex = (focusedIndex + 1) % filterButtons.length; - filterButtons[nextIndex].focus(); - break; - - case 'ArrowLeft': - case 'ArrowUp': - e.preventDefault(); - nextIndex = (focusedIndex - 1 + filterButtons.length) % filterButtons.length; - filterButtons[nextIndex].focus(); - break; - - case 'Home': - e.preventDefault(); - filterButtons[0].focus(); - break; - - case 'End': - e.preventDefault(); - filterButtons[filterButtons.length - 1].focus(); - break; - - case 'Enter': - case ' ': - e.preventDefault(); - document.activeElement.click(); - break; - } - } - }); - } - } - - // Handle focus management for better accessibility - function setupFocusManagement() { - // Ensure card links have appropriate focus styles - const cardLinks = document.querySelectorAll('.content-card-link'); - - cardLinks.forEach(link => { - // Set proper focus styles - link.addEventListener('focus', function() { - this.querySelector('.improved-card').classList.add('focus-visible'); - }); - - link.addEventListener('blur', function() { - this.querySelector('.improved-card').classList.remove('focus-visible'); - }); - }); - } - - // Initialize lazy loading with skeleton effect - function initLazyLoadingWithSkeletons() { - // Set up intersection observer for lazy loading - const imgObserver = new IntersectionObserver(function(entries, observer) { - entries.forEach(function(entry) { - if (entry.isIntersecting) { - const container = entry.target; - const imageBg = container.querySelector('.card-image-bg'); - const skeletonLoader = container.querySelector('.skeleton-loading'); - - if (imageBg && skeletonLoader) { - // Show skeleton while loading - skeletonLoader.style.opacity = '1'; - - // Create a new image to test loading - const img = new Image(); - img.src = imageBg.getAttribute('data-src'); - - img.onload = function() { - // Hide skeleton when loaded - skeletonLoader.style.opacity = '0'; - - // Add loaded class for any additional styling - container.classList.add('image-loaded'); - - // Stop observing this element - observer.unobserve(container); - }; - - img.onerror = function() { - // Hide skeleton even if there's an error - skeletonLoader.style.opacity = '0'; - observer.unobserve(container); - }; - } - } - }); - }, { - rootMargin: '100px', // Load images when they're within 100px of viewport - threshold: 0.1 + // Re-append to DOM to sort visually + grid.innerHTML = ''; + items.forEach((item, index) => { + grid.appendChild(item); }); - - // Observe all image containers - document.querySelectorAll('.loading-container').forEach(function(container) { - imgObserver.observe(container); - }); - } -<% end %> - - - - - - \ No newline at end of file + diff --git a/app/views/cards/intros/_content_type_intro.html.erb b/app/views/cards/intros/_content_type_intro.html.erb index 190449f65..9e86057f5 100644 --- a/app/views/cards/intros/_content_type_intro.html.erb +++ b/app/views/cards/intros/_content_type_intro.html.erb @@ -4,15 +4,16 @@ %> <%= link_to send("#{content_name}_worldbuilding_info_path"), class: 'black-text' do %> -
    -
    - <%= image_tag "card-headers/#{content_name.pluralize}.webp", height: 300, width: 300, style: 'object-fit: cover' %> -

    Create <%= content_name == "magic" ? 'magic' : content_name.pluralize %>

    -
    -
    -

    - <%= t("content_descriptions.#{content_name}") %> -

    -
    +
    + <%= image_tag "card-headers/#{content_name.pluralize}.webp", height: 300, width: 300, class: 'h-64 rounded w-full object-cover object-center mb-6' %> +

    + FREE FOR ALL USERS +

    +

    + Create <%= content_name == "magic" ? 'magic' : content_name.pluralize %> +

    +

    + <%= t("content_descriptions.#{content_name}") %> +

    <% end %> \ No newline at end of file diff --git a/app/views/cards/serendipitous/_tailwind_content_question.html.erb b/app/views/cards/serendipitous/_tailwind_content_question.html.erb new file mode 100644 index 000000000..6403a8909 --- /dev/null +++ b/app/views/cards/serendipitous/_tailwind_content_question.html.erb @@ -0,0 +1,167 @@ +<% + # DEPRECATED: This partial has been merged into app/views/main/components/_serendipitous_question.html.erb + # and is no longer used. It's kept here temporarily for reference. + # + # Partial locals: `content` to ask a question about, and `field` for the field being questioned + include_quick_reference = defined?(include_quick_reference) && !!include_quick_reference +%> + + + +

    + <%= + t( + "serendipitous_questions.attributes.#{content.page_type.downcase}.#{field.label.downcase}", + name: content.name, + default: "What is #{content.name}'s #{field.label.downcase}?" + ) + %> +

    + +
    +
    + + +
    + + <%= form_for content, url: FieldTypeService.form_path_from_attribute_field(field), method: :patch, html: { class: "serendipitous-question-form" } do |f| %> + <%= hidden_field(:override, :redirect_path, value: request.fullpath) %> + <%= hidden_field_tag "entity[entity_id]", content.id %> + <%= hidden_field_tag "entity[entity_type]", content.page_type %> + <%= hidden_field_tag "field[name]", field[:id] %> + + <% + field_value = field.attribute_values.find_by( + user: content.user, + entity_type: content.page_type, + entity_id: content.id + ).try(:value) + + placeholder = I18n.translate "attributes.#{content.page_type.downcase}.#{field.label.downcase.gsub(/\s/, '_')}", + scope: :serendipitous_questions, + name: content.name || "this #{content.page_type.downcase}", + default: 'Write as little or as much as you want!' + %> + +
    + <%= + text_area_tag "field[value]", + field_value, + class: "w-full rounded-t-lg focus:border-notebook-blue h-48 border-gray-300 js-autosave js-can-mention-pages resize-none transition-all duration-200", + placeholder: placeholder + %> + +
    + <% end %> +
    + + + +<% if defined?(field) && field.present? && false %> +
      +
    • +
      + help + +
      +
      + <%= form_for content, url: FieldTypeService.form_path_from_attribute_field(field), method: :patch do |f| %> + <%= hidden_field(:override, :redirect_path, value: redirect_path) if defined?(redirect_path) %> + + <%= hidden_field_tag "entity[entity_id]", content.id %> + <%= hidden_field_tag "entity[entity_type]", content.page_type %> + + <%= + render 'content/form/text_input_for_content_page', + f: f, + content: content, + field: field + %> + + <%= button_tag(type: 'submit', class: "js-content-question-submit waves-effect waves-light btn blue white-text right") do %> + Save answer + <% end %> + + <% if include_quick_reference %> + <%= link_to content.view_path, class: 'entity-trigger sidenav-trigger orange white-text btn tooltipped', data: { target: "quick-reference-#{content.page_type}-#{content.id}", tooltip: "View this #{content.page_type.downcase} without leaving this page" } do %> + vertical_split + Quick-reference + <% end %> + <% end %> + <% if !defined?(show_view_button) || !!show_view_button %> + <%= link_to content.view_path, class: "btn #{content.color} white-text tooltipped", target: '_new', data: { tooltip: "View this #{content.name.downcase} in a new tab" } do %> + <%= content.icon %> + View + <% end %> + <% end %> +
      + <% end %> +
      +
    • +
    + + <% if include_quick_reference %> + <%= render partial: 'prompts/smart_sidebar', locals: { content: content } %> + <% end %> + +<% elsif defined?(show_empty_prompt) && show_empty_prompt %> +
    +
    +
    Hey! It looks like I don't have any more prompts for you right now. I'll get to work coming up with some!
    +

    In the meantime, I'll get a big boost of prompts if you create more pages I can think about! You can also check back later and I might have more prompts for your current pages.

    + <% new_content_types = (current_user.createable_content_types - [Universe]) %> + <% new_content_types.each do |content_type| %> + <%= link_to new_polymorphic_path(content_type), class: "btn #{content_type.color} black-text", style: 'margin: 14px;' do %> + create + <% if current_user.send(content_type.name.downcase.pluralize).any? %> + another + <% else %> + <%= %w(a e i o).include?(content_type.name.downcase[0]) ? 'an' : 'a' %> + <% end %> + <%= content_type.name.downcase %> + <% end %> + <% end %> +
    +
    + <%= image_tag 'tristan/small.webp', + class: 'tooltipped tristan', + data: { + position: 'right', + enterDelay: '500', + tooltip: "Hey, I'm Tristan! I'm here to help you around the site!" + } %> +
    +
    +<% end %> diff --git a/app/views/content/_header.html.erb b/app/views/content/_header.html.erb new file mode 100644 index 000000000..9cdd8669a --- /dev/null +++ b/app/views/content/_header.html.erb @@ -0,0 +1,90 @@ + diff --git a/app/views/content/_hero_header.html.erb b/app/views/content/_hero_header.html.erb new file mode 100644 index 000000000..5990e498f --- /dev/null +++ b/app/views/content/_hero_header.html.erb @@ -0,0 +1,67 @@ + +
    + <% + # Get images using the same logic as dashboard cards + regular_images = content.image_uploads.ordered.to_a + # Filter for public if the current user isn't the owner + unless user_signed_in? && content.user_id == current_user.id + regular_images = regular_images.select { |img| img.privacy == 'public' } + end + basil_images = content.basil_commissions.where.not(saved_at: nil).ordered.to_a + + # Use get_preview_image to prioritize pinned images, then randomize + preview_image = get_preview_image(regular_images, basil_images) + %> + + <% if preview_image.present? %> + <% image_type = preview_image[:type] %> + <% image_data = preview_image[:data] %> + + <% if image_type == 'image_upload' %> + <%= image_tag image_data.src.url, + class: "w-full h-full object-cover", + alt: "#{content.name} banner image" %> + <% elsif image_type == 'basil_commission' %> + <% if image_data.image.attached? %> + <%= image_tag rails_blob_path(image_data.image, disposition: "attachment"), + class: "w-full h-full object-cover", + alt: "#{content.name} banner image" %> + <% else %> + <%= image_tag "card-headers/#{content.class.name.downcase.pluralize}.jpg", + class: "w-full h-full object-cover", + alt: "#{content.name} banner image" %> + <% end %> + <% end %> + <% else %> + <%= image_tag "card-headers/#{content.class.name.downcase.pluralize}.jpg", + class: "w-full h-full object-cover", + alt: "#{content.name} banner image" %> + <% end %> + + +
    +
    + +
    +
    + +
    +
    + <%= serialized_content.class_icon %> +
    +
    + + +
    + +

    + <%= link_to content.name, content %> +

    + + +
    +
    +
    +
    +
    +
    diff --git a/app/views/content/attributes.html.erb b/app/views/content/attributes.html.erb index d3da2a4cf..e011b5e8a 100644 --- a/app/views/content/attributes.html.erb +++ b/app/views/content/attributes.html.erb @@ -2,111 +2,345 @@ <%= render partial: 'content/components/parallax_header', locals: { content_type: @content_type, content_class: @content_type_class } %> <% end %> - +
    +
    + + +
    -
    - <%= link_to '#' do %> -
    -
    -
    Customize color
    - You can recolor this page. + + +
    + +
    + + +
    + +
    + <%= image_tag "card-headers/#{@content_type.downcase.pluralize}.jpg", + class: "hero-bg-image w-full h-full object-cover", + alt: "#{@content_type.titleize} background" %> + +
    +
    -
    - <% end %> -
    -
    - <%= link_to '#' do %> -
    -
    -
    Customize header image
    - You can change the header image. + + +
    +
    + +
    +
    + <%= @content_type_class.icon %> +
    +
    +

    + <%= @content_type.titleize %> Template Editor +

    +

    + Edit the template used for your <%= @content_type.titleize.downcase %> pages. Adding or removing a field here will modify all of your already-created <%= @content_type.titleize.downcase %> pages also. +

    +
    +
    +
    - <% end %> -
    -
    ---> -
    -
      - <%= render partial: 'content/attributes/general_settings', locals: { content_type: @content_type, content_type_class: @content_type_class } %> - - <% @attribute_categories.each do |attribute_category| %> -
    • -
      - menu - <%= attribute_category.icon %> -
      - <%= attribute_category.label %> + +
      + <% @attribute_categories.each do |category| %> + <%= render partial: 'content/attributes/tailwind/category_card', locals: { + category: category, + content_type_class: @content_type_class, + content_type: @content_type + } %> + <% end %> + + +
      +
      + add_circle_outline + Add a New Category
      - <% if attribute_category.hidden? %> - - visibility_off - <% end %> -
      - -
      -
      - <% if attribute_category.hidden? %> -
      - <%= render partial: 'content/attributes/category_visibility_controls', locals: { category: attribute_category } %> + + + -
    • - <% end %> -
    • -
      - add - Add another category
      -
      - <%= form_for(current_user.attribute_categories.build, method: :post) do |f| %> - <%= f.hidden_field :entity_type, value: @content_type %> -
      -
      - <%= f.text_area :label, class: 'materialize-textarea js-category-input' %> - <%= f.label :label, 'Category label' %> -
      -
      - <%= f.submit 'Add new category', class: "btn #{@content_type_class.color}" %> +
      + + +
      + + +
      + + +
      +
      + tune +
      +

      Configure Your Template

      +

      + Select a category or field to customize it, or add a new one to get started. +

      +

      Tip: You can drag and drop to reorder categories and fields.

      + +
      + + +
      + +
      +
      +
      + Loading field settings...
      - -
      -
      - New: Notebook.ai can now suggest additional categories for your pages. +
      + + +
      + +
      +
      +
      + Loading category settings...
      -

      -

      - <%= f.button "Show suggestions", class: 'small btn white black-text js-show-category-suggestions' %>
      - <% end %> -
      -
    • -
    -
    \ No newline at end of file +
    + + +
    + <%= render partial: 'content/attributes/tailwind/general_settings', locals: { + content_type: @content_type, + content_type_class: @content_type_class + } %> +
    + +
    +
    +
    +
    + + + + + + \ No newline at end of file diff --git a/app/views/content/attributes/fields/options/_link.html.erb b/app/views/content/attributes/fields/options/_link.html.erb index 7d554b043..e64a1ab9c 100644 --- a/app/views/content/attributes/fields/options/_link.html.erb +++ b/app/views/content/attributes/fields/options/_link.html.erb @@ -16,7 +16,7 @@ + +
    + <% Rails.application.config.content_types[:all].each do |content_type| %> + + <% end %> +
    +

    Select which page types users can link to from this field.

    +
    + +
    + + <%= f.submit 'Add Link Field', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white", style: "background-color: #{content_type_class.hex_color};" %> +
    + <% end %> +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/content/attributes/tailwind/_category_config.html.erb b/app/views/content/attributes/tailwind/_category_config.html.erb new file mode 100644 index 000000000..bedd3b993 --- /dev/null +++ b/app/views/content/attributes/tailwind/_category_config.html.erb @@ -0,0 +1,187 @@ +
    +
    +

    Configure Category

    + +
    + + +
    +
    + +
    + + +
    + <%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %> + <%= f.hidden_field :name %> + <%= f.hidden_field :entity_type %> +
    + + <%= f.text_field :label, class: "shadow-sm block w-full sm:text-sm border-gray-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %> +
    + +
    + +
    + <% MATERIAL_ICONS.each do |icon| %> +
    + +
    + <% end %> +
    + <%= f.hidden_field :icon, id: 'attribute_category_icon' %> +
    + + +
    + <%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> +
    + <% end %> +
    + + + +
    +
    +
    +
    Display Order
    +
    +

    + Categories are displayed in order on content pages. Lower numbers appear first. +

    +

    + Tip: Use drag & drop in the template editor for easier reordering. This manual control is for fine-tuning. +

    + <%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'space-y-3', 'data-type': 'json'}, remote: true) do |f| %> +
    + + <%= f.number_field :position, class: "w-20 shadow-sm border-gray-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %> + <%= f.submit 'Update', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> +
    + <% end %> +
    +
    + +
    +
    Archives
    +
    + <%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :put, html: {class: 'inline', 'data-type': 'json'}, remote: true) do |f| %> + <%= f.hidden_field :name %> + <%= f.hidden_field :entity_type %> + <%= f.hidden_field :hidden, value: !category.hidden %> + <% if category.hidden? %> +
    +
    + archive +
    +
    +

    This category is currently archived

    +

    It's hidden from your workspace but can be restored anytime.

    +
    +
    + <%= f.submit 'Restore this category', class: "px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %> + <% else %> +
    +
    + check_circle +
    +
    +

    This category is currently active

    +

    It appears in your template and content pages.

    +
    +
    + <%= f.submit 'Archive this category', class: "px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %> + <% end %> + <% end %> +
    +
    + + +
    +
    + warning + Danger Zone +
    +
    +

    Delete this category

    +

    + This will permanently delete the category, all <%= pluralize(category.attribute_fields.count, 'field') %> in it, + and all content data stored in these fields across your <%= content_type.titleize.downcase %> pages. +

    +

    + This action cannot be undone. +

    + +
    +
    + +
    + +
    +

    + Are you absolutely sure? Type "<%= category.label %>" to confirm: +

    + + <%= form_for(category, url: "/plan/attribute_categories/#{category.id}", method: :delete, html: {class: 'space-y-3', 'data-type': 'json', 'x-data': '{ confirmText: "" }'}, remote: true) do |f| %> + + +
    + + +
    + <% end %> +
    +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/content/attributes/tailwind/_field_config.html.erb b/app/views/content/attributes/tailwind/_field_config.html.erb new file mode 100644 index 000000000..350055ca8 --- /dev/null +++ b/app/views/content/attributes/tailwind/_field_config.html.erb @@ -0,0 +1,499 @@ +
    +
    +

    Configure Field

    + +
    + + +
    +
    + +
    + + +
    + <%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %> +
    + + <%= f.text_field :label, class: "shadow-sm block w-full sm:text-sm border-gray-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %> +
    + +
    + +
    +
    + <% if field.field_type == 'text_area' %> + text_fields + <% elsif field.field_type == 'link' %> + link + <% elsif field.field_type == 'name' %> + label + <% elsif field.field_type == 'universe' %> + language + <% elsif field.field_type == 'tags' %> + local_offer + <% else %> + subject + <% end %> +
    + + <%= field.field_type.humanize %> + <% if field.name_field? || field.universe_field? || field.tags_field? %> + (Cannot be changed) + <% end %> + +
    +
    + + <% if field.field_type == 'link' %> +
    +
    + +
    + + +
    +
    + + + + +
    + <% Rails.application.config.content_types[:all].each do |content_type| %> + + <% end %> +
    +

    Select which page types users can link to from this field.

    + +
    + <% end %> + +
    + <%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> +
    + <% end %> +
    + + +
    +
    + <% if field.field_type == 'text_area' %> + <%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %> +
    + + + + +
    + +
    + + + + + + + + +
    +
    + +
    + <%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> +
    +
    + <% end %> + <% elsif field.field_type == 'link' %> +
    +

    + Choose how linked content appears on your pages. +

    + + <%= form_for(field, method: :put, html: {class: 'space-y-4', 'data-type': 'json'}, remote: true) do |f| %> + + + <% if field.field_options&.dig('linkable_types')&.any? %> + <% field.field_options['linkable_types'].each do |linkable_type| %> + + <% end %> + <% end %> + +
    + + + +
    + + + + + +
    + + +
    + +
    + +
    +
    + + <%= defined?(Character) ? Character.icon : 'person' %> + Alice Johnson + + + <%= defined?(Location) ? Location.icon : 'place' %> + The Rusty Tavern + + + <%= defined?(Item) ? Item.icon : 'category' %> + Magic Sword + +
    +
    + + +
    +
    • Alice Johnson
    +
    • The Rusty Tavern
    +
    • Magic Sword
    +
    + + +
    +
    +
    + +
    +
    + <%= image_tag 'card-headers/characters.jpg', class: 'w-full h-full object-cover', alt: 'Character image' %> +
    +
    +
    Alice Johnson
    +
    + <%= defined?(Character) ? Character.icon : 'person' %> + Character +
    +
    +
    + + +
    +
    + <%= image_tag 'card-headers/locations.jpg', class: 'w-full h-full object-cover', alt: 'Location image' %> +
    +
    +
    The Rusty Tavern
    +
    + <%= defined?(Location) ? Location.icon : 'place' %> + Location +
    +
    +
    + + +
    +
    + <%= image_tag 'card-headers/items.jpg', class: 'w-full h-full object-cover', alt: 'Item image' %> +
    +
    +
    Magic Sword
    +
    + <%= defined?(Item) ? Item.icon : 'category' %> + Item +
    +
    +
    + + +
    +
    + <%= image_tag 'card-headers/characters.jpg', class: 'w-full h-full object-cover', alt: 'Character image' %> +
    +
    +
    Sir Marcus
    +
    + <%= defined?(Character) ? Character.icon : 'person' %> + Character +
    +
    +
    + +
    +
    + <%= image_tag 'card-headers/locations.jpg', class: 'w-full h-full object-cover', alt: 'Location image' %> +
    +
    +
    Ancient Library
    +
    + <%= defined?(Location) ? Location.icon : 'place' %> + Location +
    +
    +
    +
    +
    +
    +
    +
    + +
    + <%= f.submit 'Save Changes', class: "px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> +
    +
    + <% end %> +
    + <% else %> +
    +
    + palette +
    +

    + No appearance options available for this field type. +

    +
    + <% end %> +
    +
    + + +
    +
    +
    +
    Display Order
    +
    +

    + Fields are displayed in order within their category. Lower numbers appear first. +

    +

    + Tip: Use drag & drop in the template editor for easier reordering. This manual control is for fine-tuning. +

    + <%= form_for(field, method: :put, html: {class: 'space-y-3', 'data-type': 'json'}, remote: true) do |f| %> +
    + + <%= f.number_field :position, class: "w-20 shadow-sm border-gray-300 dark:border-slate-600 dark:bg-slate-900 dark:text-white rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color}; border-color: #{content_type_class.hex_color};" %> + <%= f.submit 'Update', class: "px-3 py-1.5 border border-transparent rounded-md shadow-sm text-xs font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2", style: "background-color: #{content_type_class.hex_color}; --tw-ring-color: #{content_type_class.hex_color};" %> +
    + <% end %> +
    +
    + +
    +
    Sharing Restrictions
    +
    +
    +
    + Note: All <%= content_type.downcase %> pages are private by default, but these settings affect any page that is public. +
    +
    +
    + + id="field_private" + class="h-4 w-4 border-gray-300 rounded focus:ring-2 focus:ring-offset-2" + style="color: <%= content_type_class.hex_color %>; --tw-ring-color: <%= content_type_class.hex_color %>;"> + +
    +

    Private fields (and their answers) are only visible to you, even when your pages are shared publicly.

    +
    +
    + +
    +
    Archives
    +
    + <%= form_for(field, method: :put, html: {class: 'inline', 'data-type': 'json'}, remote: true) do |f| %> + <%= f.hidden_field :hidden, value: !field.hidden %> + <% if field.hidden? %> +
    +
    + archive +
    +
    +

    This field is currently archived

    +

    It's hidden from your workspace but can be restored anytime.

    +
    +
    + <%= f.submit 'Restore this field', class: "px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %> + <% else %> +
    +
    + check_circle +
    +
    +

    This field is currently active

    +

    It appears in your template and content pages.

    +
    +
    + <%= f.submit 'Archive this field', class: "px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2", style: "--tw-ring-color: #{content_type_class.hex_color};" %> + <% end %> + <% end %> +
    +
    + + <% unless field.name_field? || field.universe_field? || field.tags_field? %> +
    +
    + warning + Danger Zone +
    +
    +
    +

    + Deleting a field will permanently remove it and all its data from all <%= content_type.pluralize.downcase %>. +

    + + +
    + + +
    +
    +

    + warning + Confirm Field Deletion +

    +

    + This will permanently delete "<%= field.label %>" and all answers you've written to this field across all of your <%= content_type.pluralize.downcase %> pages. +

    +

    + This action cannot be undone! +

    +
    + +
    + + +
    +
    +
    +
    + <% end %> +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/content/attributes/tailwind/_field_item.html.erb b/app/views/content/attributes/tailwind/_field_item.html.erb new file mode 100644 index 000000000..5abc7621a --- /dev/null +++ b/app/views/content/attributes/tailwind/_field_item.html.erb @@ -0,0 +1,99 @@ +
    style="display: none;"<% end %>> + +
    + + + +
    + + +
    + <% if field.field_type == 'text_area' %> + text_fields + <% elsif field.field_type == 'link' %> + link + <% elsif field.field_type == 'name' %> + label + <% elsif field.field_type == 'universe' %> + language + <% elsif field.field_type == 'tags' %> + local_offer + <% else %> + subject + <% end %> +
    + + +
    +
    + <%= field.label %> + <% if field.hidden? %> + archive + <% end %> +
    + + + <% if field.field_type == 'link' && field.field_options&.dig('linkable_types')&.any? %> +
    + link + <% linkable_types = field.field_options['linkable_types'] || [] %> + <% content_types = get_linkable_content_types(linkable_types) %> + <% content_types.first(5).each do |content_type| %> + + <%= content_type.icon %> + + <% end %> + <% if content_types.length > 5 %> + +<%= content_types.length - 5 %> more + <% end %> +
    + <% end %> + +
    + <% if field.name_field? %> + Name field + <% elsif field.universe_field? %> + Universe field + <% elsif field.tags_field? %> + Tags field + <% elsif field.field_type == 'link' %> + Link field + <% else %> + Text field + <% end %> + + <% if field.hidden? %> + — Archived + <% end %> + + <% if field.label.start_with?('Private') %> + — Private + <% end %> +
    +
    + + +
    + + <% unless field.name_field? %> + + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/content/attributes/tailwind/_general_settings.html.erb b/app/views/content/attributes/tailwind/_general_settings.html.erb new file mode 100644 index 000000000..f2824a4a8 --- /dev/null +++ b/app/views/content/attributes/tailwind/_general_settings.html.erb @@ -0,0 +1,281 @@ +
    +
    +

    + settings + General Settings +

    + +
    + +
    + +
    +

    + <%= content_type_class.icon %> + <%= content_type.titleize %> Template +

    +

    + This template defines the structure and fields for your <%= content_type.titleize.downcase %> pages. +

    +
    +
    + Categories: + <%= @attribute_categories.count %> +
    +
    + Total Fields: + <%= @attribute_categories.sum { |cat| cat.attribute_fields.count } %> +
    +
    +
    + + +
    +

    Template Actions

    +
    +
    + +
    + +

    + YAML/JSON for technical users, Markdown/CSV for human readability. +

    +
    +
    + +
    + +
    +
    +
    + construction +
    +

    Coming Soon!

    +

    + Template import functionality is currently in development. Soon you'll be able to: +

    +
      +
    • • Import YAML/JSON template files
    • +
    • • Share templates with other users
    • +
    • • Load community-created templates
    • +
    • • Restore from template backups
    • +
    +
    +
    +
    + +
    + + +
    + + +
    +
    +

    Analyzing template...

    +
    + + +
    +
    +

    + warning + Reset Impact Analysis +

    +
    +

    categories and fields will be deleted

    +

    custom categories you created

    +

    + fields have data that will be permanently lost! +

    +

    + filled answers will be deleted across different pages +

    +
    +
    + + +
    +
    ⚠️ Data Loss Warning
    +
    + +
    +
    + +
    + + +
    +
    + + +
    +
    +

    ⚠️ Final Confirmation Required

    +

    + Type "<%= content_type.titleize %>" below to confirm you want to permanently reset this template: +

    + +
    + +
    + + +
    +
    +
    +
    +
    +
    + + + +
    +

    Editor Settings

    +
    +
    +
    +
    Show archived items
    +
    + +
    +
    +
    + + +
    +
    +
    + info + No archived items. Archive categories or fields to organize your workspace. +
    +
    +
    + + +
    +

    + help_outline + Need Help? +

    +

    + Learn more about customizing your templates and organizing your content. +

    + <%= link_to help_page_templates_path, class: "text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300 flex items-center", target: "_blank" do %> + View Documentation + open_in_new + <% end %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/content/changelog.html.erb b/app/views/content/changelog.html.erb index d49b42bcb..8e83d81d6 100644 --- a/app/views/content/changelog.html.erb +++ b/app/views/content/changelog.html.erb @@ -1,5 +1,23 @@ -<%= content_for :full_width_page_header do %> - <%= render partial: 'content/display/image_card_header' %> -<% end %> + +
    -<%= render partial: 'content/display/changelog', locals: { content: @serialized_content } %> + + <%= render partial: 'content/header', locals: { + content: @serialized_content, + show_edit_controls: true, + current_page: :changelog + } %> + + +
    + + <%= render partial: 'content/changelog/header' %> + + + <%= render partial: 'content/changelog/timeline' %> +
    + +
    + + + \ No newline at end of file diff --git a/app/views/content/changelog/_date_changes.html.erb b/app/views/content/changelog/_date_changes.html.erb new file mode 100644 index 000000000..d38a2dced --- /dev/null +++ b/app/views/content/changelog/_date_changes.html.erb @@ -0,0 +1,133 @@ + +<% + # Process the events to get organized change data + changed_attributes = Attribute.where(id: events.select { |event| event.content_type == 'Attribute' }.map(&:content_id)) + changed_fields = AttributeField.where(id: changed_attributes.pluck(:attribute_field_id)).includes([:attribute_category]) +%> + +
    + <% events.reverse.each_with_index do |change_event, event_index| %> + <% + # Skip events without users (old data) + next if change_event.user.nil? + %> + + +
    +
    +
    + <% if change_event.user.avatar.attached? %> + <%= image_tag change_event.user.avatar, class: "w-full h-full rounded-full object-cover" %> + <% else %> + person + <% end %> +
    + <%= change_event.user.display_name %> + + <%= change_event.created_at.strftime('%I:%M %p') %> +
    + + <%= pluralize(change_event.changed_fields.keys.length, 'change') %> + +
    + + +
    + <% change_event.changed_fields.each do |field_key, change| %> + <% + related_attribute = changed_attributes.detect { |attribute| attribute.id == change_event.content_id } + next unless related_attribute + + related_field = changed_fields.detect { |field| field.id == related_attribute.attribute_field_id } + next unless related_field + + related_category = related_field.attribute_category + + # Skip if value didn't actually change + next if change.first == change.second + next if change.first.blank? && change.second.blank? + next if ContentChangeEvent::FIELD_IDS_TO_EXCLUDE.include?(field_key) + + # Handle privacy and blank values + old_value = change.first.blank? ? ContentChangeEvent::BLANK_PLACEHOLDER : change.first.to_s + new_value = change.second.blank? ? ContentChangeEvent::BLANK_PLACEHOLDER : change.second.to_s + + # Privacy check + visible_change = true + if related_field.label.start_with?('Private') + visible_change = user_signed_in? && ( + (content.raw_model.is_a?(Universe) && content.user == current_user) || + (content.respond_to?(:universe) && content.universe && content.universe.user == current_user) || + (content.respond_to?(:universe) && content.universe.nil? && content.user == current_user) + ) + end + + unless visible_change + old_value = ContentChangeEvent::PRIVATE_PLACEHOLDER + new_value = ContentChangeEvent::PRIVATE_PLACEHOLDER + end + + # Special handling for privacy field + if related_field.label.downcase == 'privacy' + old_value = 'private' if old_value == ContentChangeEvent::BLANK_PLACEHOLDER + new_value = 'private' if new_value == ContentChangeEvent::BLANK_PLACEHOLDER + end + %> + + +
    + +
    +
    +
    + <%= related_category.icon %> +
    +
    +

    <%= related_field.label %>

    +

    <%= related_category.label %>

    +
    +
    + + <%= change_event.action %> + +
    + + +
    + <%= render partial: "content/changelog/field_change/#{related_field.field_type}", + locals: { + old_value: old_value, + new_value: new_value, + field: related_field, + change_type: change_event.action + } %> +
    + + +
    +
    + <% unless old_value == ContentChangeEvent::BLANK_PLACEHOLDER || old_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + + <% end %> +
    +
    + <% unless new_value == ContentChangeEvent::BLANK_PLACEHOLDER || new_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + + <% end %> +
    +
    +
    + <% end %> +
    + <% end %> +
    \ No newline at end of file diff --git a/app/views/content/changelog/_header.html.erb b/app/views/content/changelog/_header.html.erb new file mode 100644 index 000000000..4b552e58c --- /dev/null +++ b/app/views/content/changelog/_header.html.erb @@ -0,0 +1,156 @@ + +
    +
    + + + + +
    +
    +
    + <%= @serialized_content.class_icon %> +
    +
    +

    + Your Creative Journey +

    +

    + Every change you've made to <%= @serialized_content.name %> +

    +
    +
    +
    + + +
    + +
    +
    +
    + edit +
    +
    +
    <%= number_with_delimiter(@stats.total_changes) %>
    +
    Total changes
    +
    +
    +
    + + +
    +
    +
    + calendar_today +
    +
    +
    <%= @stats.active_days %>
    +
    <%= @stats.active_days == 1 ? 'Day with edits' : 'Days with edits' %>
    +
    +
    +
    + + +
    +
    +
    + trending_up +
    +
    + <% biggest_update = @stats.biggest_single_update %> + <% field_count = biggest_update ? biggest_update[:field_count] : 0 %> +
    + <%= pluralize(field_count, 'change') %> +
    +
    Biggest Edit Session
    +
    +
    +
    + + +
    +
    +
    + schedule +
    +
    +
    <%= pluralize(@stats.days_since_creation, 'day') %>
    +
    Since creation
    +
    +
    +
    +
    + + +
    +

    + show_chart + Activity Over Time +

    +

    + Weekly editing activity over the past year +

    + +
    +
    + <% @change_intensity.each do |week_data| %> + <% + intensity_level = case week_data[:change_count] + when 0 then 'bg-gray-100 dark:bg-gray-700' + when 1..3 then 'bg-green-200 dark:bg-green-900' + when 4..7 then 'bg-green-400 dark:bg-green-700' + when 8..15 then 'bg-green-600 dark:bg-green-600' + else 'bg-green-800 dark:bg-green-500' + end + %> +
    +
    + <% end %> +
    +
    + + +
    + 1 year ago + Today +
    + +
    + Less +
    +
    +
    +
    +
    + More +
    +
    + + + <% longest_streak = @stats.longest_writing_streak %> + <% if longest_streak %> +
    +
    +
    + local_fire_department +
    +
    +

    Your Longest Writing Streak

    +

    + <%= longest_streak[:length] %> consecutive days + from <%= longest_streak[:start_date].strftime('%B %d') %> to <%= longest_streak[:end_date].strftime('%B %d, %Y') %> +

    +
    +
    +
    + <% end %> + +
    +
    \ No newline at end of file diff --git a/app/views/content/changelog/_timeline.html.erb b/app/views/content/changelog/_timeline.html.erb new file mode 100644 index 000000000..4fa5d57ed --- /dev/null +++ b/app/views/content/changelog/_timeline.html.erb @@ -0,0 +1,230 @@ + +
    + + +
    +

    Change Timeline

    +

    Your creative journey, day by day

    + <% if @paginated_events.total_pages > 1 %> +
    + Showing page <%= @paginated_events.current_page %> of <%= @paginated_events.total_pages %> + (<%= @paginated_events.total_entries %> total changes) +
    + <% end %> +
    + + +
    + +
    + + <% if @grouped_changes.empty? %> + +
    +
    + history +
    +

    No Changes Yet

    +

    Start editing your <%= @serialized_content.class_name.downcase %> to see your creative journey unfold here.

    +
    + <% else %> + +
    + <% @grouped_changes.each_with_index do |group, index| %> +
    + +
    + + +
    + + + + +
    + +
    + <%= render partial: 'content/changelog/date_changes', + locals: { + events: group[:events], + content: @serialized_content, + date: group[:date] + } %> +
    +
    +
    +
    + <% end %> +
    + <% end %> + + +
    + +
    + + +
    +
    +
    + star +
    +
    +

    The Beginning

    +

    + <%= @serialized_content.name %> was created + <% if @content.user.present? %> + by <%= link_to @content.user.display_name, @content.user, class: "font-medium text-green-700 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" %> + <% end %> + + <%= distance_of_time_in_words(@content.created_at, Time.current) %> ago + (<%= @content.created_at.strftime('%B %d, %Y at %I:%M %p') %>) + +

    +
    +
    +
    +
    + + + <% if @paginated_events.total_pages > 1 %> +
    +
    +
    + <% if @paginated_events.previous_page %> + <%= link_to url_for(params.permit(:page).merge(page: @paginated_events.previous_page)), + class: "flex items-center px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors text-gray-700 dark:text-gray-200" do %> + chevron_left + <% end %> + <% else %> + + chevron_left + + <% end %> + + +
    + <% + total = @paginated_events.total_pages + current = @paginated_events.current_page + + # Determine which pages to show + if total <= 7 + pages_to_show = (1..total).to_a + else + pages_to_show = [1] + + # Pages around current + start_page = [current - 1, 2].max + end_page = [current + 1, total - 1].min + + pages_to_show << :ellipsis if start_page > 2 + pages_to_show += (start_page..end_page).to_a + pages_to_show << :ellipsis if end_page < total - 1 + pages_to_show << total + + pages_to_show.uniq! + end + %> + + <% pages_to_show.each do |page_num| %> + <% if page_num == :ellipsis %> + ... + <% elsif page_num == current %> + + <%= page_num %> + + <% else %> + <%= link_to page_num, + url_for(params.permit(:page).merge(page: page_num)), + class: "px-3 py-1 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-200 transition-colors" %> + <% end %> + <% end %> +
    + + <% if @paginated_events.next_page %> + <%= link_to url_for(params.permit(:page).merge(page: @paginated_events.next_page)), + class: "flex items-center px-3 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors text-gray-700 dark:text-gray-200" do %> + chevron_right + <% end %> + <% else %> + + chevron_right + + <% end %> +
    +
    +
    + <% end %> + +
    +
    \ No newline at end of file diff --git a/app/views/content/changelog/field_change/_link.html.erb b/app/views/content/changelog/field_change/_link.html.erb index c18214b2d..789cad6aa 100644 --- a/app/views/content/changelog/field_change/_link.html.erb +++ b/app/views/content/changelog/field_change/_link.html.erb @@ -4,17 +4,36 @@ new_json = JSON.parse(new_value) rescue [] %> -
    -
    - <%= simple_format ContentFormatterService.show( - text: old_json.map { |code| "• [[#{code}]]" }.join("\n"), - viewing_user: current_user - ) %> +
    + +
    +
    +
    + Previous Links +
    +
    +
    + <%= simple_format ContentFormatterService.show( + text: old_json.map { |code| "• [[#{code}]]" }.join("\n"), + viewing_user: current_user + ) %> +
    +
    -
    - <%= simple_format ContentFormatterService.show( - text: new_json.map { |code| "• [[#{code}]]" }.join("\n"), - viewing_user: current_user - ) %> + + +
    +
    +
    + Current Links +
    +
    +
    + <%= simple_format ContentFormatterService.show( + text: new_json.map { |code| "• [[#{code}]]" }.join("\n"), + viewing_user: current_user + ) %> +
    +
    \ No newline at end of file diff --git a/app/views/content/changelog/field_change/_name.html.erb b/app/views/content/changelog/field_change/_name.html.erb index bb43a4556..d9effa1a4 100644 --- a/app/views/content/changelog/field_change/_name.html.erb +++ b/app/views/content/changelog/field_change/_name.html.erb @@ -1,14 +1,41 @@ -
    -
    - <%= simple_format ContentFormatterService.show( - text: old_value, - viewing_user: current_user - ) %> + +
    + +
    + From: + + <% if old_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + Empty + <% elsif old_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private + <% else %> + <%= simple_format ContentFormatterService.show( + text: old_value, + viewing_user: current_user + ) %> + <% end %> +
    -
    - <%= simple_format ContentFormatterService.show( - text: new_value, - viewing_user: current_user - ) %> + + +
    + arrow_forward +
    + + +
    + To: + + <% if new_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + Empty + <% elsif new_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private + <% else %> + <%= simple_format ContentFormatterService.show( + text: new_value, + viewing_user: current_user + ) %> + <% end %> +
    \ No newline at end of file diff --git a/app/views/content/changelog/field_change/_tags.html.erb b/app/views/content/changelog/field_change/_tags.html.erb index 2e9a7a917..69b94f880 100644 --- a/app/views/content/changelog/field_change/_tags.html.erb +++ b/app/views/content/changelog/field_change/_tags.html.erb @@ -1,41 +1,66 @@ -
    - -
    -
    - <% old_value.split(PageTag::SUBMISSION_DELIMITER).each do |tag| %> - <% if user_signed_in? && @content.user == current_user %> - <%= - link_to send( - "page_tag_#{@content.class.name.downcase.pluralize}_path", - slug: PageTagService.slug_for(tag) - ) do - %> - + +
    + +
    +
    +
    + Previous Tags +
    +
    + <% if old_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + No tags + <% elsif old_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private field + <% else %> +
    + <% old_value.split(PageTag::SUBMISSION_DELIMITER).each do |tag| %> + <% if user_signed_in? && @content.user == current_user %> + <%= link_to send("page_tag_#{@content.class.name.downcase.pluralize}_path", slug: PageTagService.slug_for(tag)), + class: "inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900 dark:text-red-200 dark:hover:bg-red-800 transition-colors" do %> + tag + <%= tag %> + <% end %> + <% else %> + + tag + <%= tag %> + + <% end %> <% end %> - <% else %> - - <% end %> +
    <% end %>
    -
    -
    - <% new_value.split(PageTag::SUBMISSION_DELIMITER).each do |tag| %> - <% if user_signed_in? && @content.user == current_user %> - <%= - link_to send( - "page_tag_#{@content.class.name.downcase.pluralize}_path", - slug: PageTagService.slug_for(tag) - ) do - %> - + +
    +
    +
    + Current Tags +
    +
    + <% if new_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + No tags + <% elsif new_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private field + <% else %> +
    + <% new_value.split(PageTag::SUBMISSION_DELIMITER).each do |tag| %> + <% if user_signed_in? && @content.user == current_user %> + <%= link_to send("page_tag_#{@content.class.name.downcase.pluralize}_path", slug: PageTagService.slug_for(tag)), + class: "inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900 dark:text-green-200 dark:hover:bg-green-800 transition-colors" do %> + tag + <%= tag %> + <% end %> + <% else %> + + tag + <%= tag %> + + <% end %> <% end %> - <% else %> - - <% end %> +
    <% end %>
    -
    \ No newline at end of file diff --git a/app/views/content/changelog/field_change/_text_area.html.erb b/app/views/content/changelog/field_change/_text_area.html.erb index bb43a4556..d22dec9f3 100644 --- a/app/views/content/changelog/field_change/_text_area.html.erb +++ b/app/views/content/changelog/field_change/_text_area.html.erb @@ -1,14 +1,74 @@ -
    -
    - <%= simple_format ContentFormatterService.show( - text: old_value, - viewing_user: current_user - ) %> + +
    + +
    +
    +
    + Previous + <% if old_value != ContentChangeEvent::BLANK_PLACEHOLDER && old_value != ContentChangeEvent::PRIVATE_PLACEHOLDER %> + + <%= old_value.to_s.split.length %> words + + <% end %> +
    +
    + <% if old_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + Empty + <% elsif old_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private field + <% else %> +
    + <%= simple_format ContentFormatterService.show( + text: old_value, + viewing_user: current_user + ) %> +
    + <% end %> +
    -
    - <%= simple_format ContentFormatterService.show( - text: new_value, - viewing_user: current_user - ) %> + + +
    +
    +
    + Current + <% if new_value != ContentChangeEvent::BLANK_PLACEHOLDER && new_value != ContentChangeEvent::PRIVATE_PLACEHOLDER %> + + <%= new_value.to_s.split.length %> words + + <% end %> +
    +
    + <% if new_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + Empty + <% elsif new_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private field + <% else %> +
    + <%= simple_format ContentFormatterService.show( + text: new_value, + viewing_user: current_user + ) %> +
    + <% end %> +
    -
    \ No newline at end of file +
    + + +<% if old_value != ContentChangeEvent::BLANK_PLACEHOLDER && old_value != ContentChangeEvent::PRIVATE_PLACEHOLDER && + new_value != ContentChangeEvent::BLANK_PLACEHOLDER && new_value != ContentChangeEvent::PRIVATE_PLACEHOLDER %> + <% + old_word_count = old_value.to_s.split.length + new_word_count = new_value.to_s.split.length + word_diff = new_word_count - old_word_count + %> + <% if word_diff != 0 %> +
    + + <%= word_diff > 0 ? 'add' : 'remove' %> + <%= word_diff.abs %> <%= 'word'.pluralize(word_diff.abs) %> <%= word_diff > 0 ? 'added' : 'removed' %> + +
    + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/content/changelog/field_change/_universe.html.erb b/app/views/content/changelog/field_change/_universe.html.erb index 010878434..727a5eec0 100644 --- a/app/views/content/changelog/field_change/_universe.html.erb +++ b/app/views/content/changelog/field_change/_universe.html.erb @@ -1,22 +1,47 @@ -
    -
    - <% if old_value.blank? %> -

    (no universe)

    - <% else %> - <%= simple_format ContentFormatterService.show( - text: "[[Universe-#{old_value}]]", - viewing_user: current_user - ) %> - <% end %> + +
    + +
    + From: +
    + public +
    + <% if old_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + No universe + <% elsif old_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private + <% else %> + <%= simple_format ContentFormatterService.show( + text: "[[Universe-#{old_value}]]", + viewing_user: current_user + ) %> + <% end %> +
    +
    -
    - <% if new_value.blank? %> -

    (no universe)

    - <% else %> - <%= simple_format ContentFormatterService.show( - text: "[[Universe-#{new_value}]]", - viewing_user: current_user - ) %> - <% end %> + + +
    + arrow_forward +
    + + +
    + To: +
    + public +
    + <% if new_value == ContentChangeEvent::BLANK_PLACEHOLDER %> + No universe + <% elsif new_value == ContentChangeEvent::PRIVATE_PLACEHOLDER %> + Private + <% else %> + <%= simple_format ContentFormatterService.show( + text: "[[Universe-#{new_value}]]", + viewing_user: current_user + ) %> + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/content/deleted.html.erb b/app/views/content/deleted.html.erb index 92756583e..0618dcaad 100644 --- a/app/views/content/deleted.html.erb +++ b/app/views/content/deleted.html.erb @@ -1,144 +1,187 @@ -

    - <%= link_to data_vault_path, class: 'grey-text tooltipped', style: 'position: relative; top: 4px;', data: { - position: 'bottom', - enterDelay: '500', - tooltip: "Back to your Data Vault" - } do %> - arrow_back - <% end %> - Your notebook's recycle bin -

    +
    +
    + <%= link_to data_vault_path, class: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 mr-3', title: "Back to your Data Vault" do %> + arrow_back + <% end %> +

    Recycle Bin

    +
    -
    -
    -
    -
    -
    -
    -

    - Whenever you delete a page from your notebook, it ends up here for a little while. - While a page is here, you can recover it at any time to add it back to your notebook. - If not recovered, the page will be automatically and permanently deleted after a certain - period of time. -

    -
    -

    - In other words, use this page if you've accidentally deleted a page and need to recover it. -

    -
    -

    - Premium users can recover pages up to 7 days after their deletion.
    - All other users can recover pages up to 2 days after their deletion. -

    -
    -
    - <%= image_tag 'tristan/small.webp', - class: 'tooltipped tristan', - data: { - position: 'left', - enterDelay: '500', - tooltip: "Hey, I'm Tristan! I'm happy to help you around Notebook.ai." - } %> + +
    +
    +
    +
    +

    Recently Deleted Content

    +

    + Whenever you delete a page from your notebook, it ends up here for a limited time. + You can recover any page during this period to add it back to your notebook. + If not recovered, pages will be permanently deleted after the recovery period ends. +

    +

    + Use this page if you've accidentally deleted content and need to recover it. +

    + +
    +
    +
    + alarm +
    +
    +

    Recovery Period

    +
    +

    + <% if current_user.on_premium_plan? %> + Premium users can recover pages up to 7 days after deletion. + <% else %> + You can recover pages up to 2 days after deletion. + + + Upgrade to Premium + + for a 7-day recovery period. + + <% end %> +

    +
    +
    +
    +
    -
    -
    -
    - <% showed_any_content = false %> + <% showed_any_content = false %> - <% @content_pages.each do |content_type_name, content_list| %> - <% next unless content_list.any? %> - <% showed_any_content = true %> + <% @content_pages.each do |content_type_name, content_list| %> + <% next unless content_list.any? %> + <% showed_any_content = true %> - <% - content_type = content_class_from_name(content_type_name) - category_ids_for_this_content_type = AttributeCategory.where(entity_type: content_type_name.downcase, user_id: current_user).pluck(:id) - name_field = AttributeField.find_by(field_type: 'name', attribute_category_id: category_ids_for_this_content_type) + <% + content_type = content_class_from_name(content_type_name) + category_ids_for_this_content_type = AttributeCategory.where(entity_type: content_type_name.downcase, user_id: current_user).pluck(:id) + name_field = AttributeField.find_by(field_type: 'name', attribute_category_id: category_ids_for_this_content_type) - content_ids = content_list.pluck(:id) - if name_field - list_name_lookup_cache = Hash[ - name_field.attribute_values.where( - entity_type: content_type_name - ).pluck(:entity_id, :value) - ] - else - list_name_lookup_cache = {} - end - %> + content_ids = content_list.pluck(:id) + if name_field + list_name_lookup_cache = Hash[ + name_field.attribute_values.where( + entity_type: content_type_name + ).pluck(:entity_id, :value) + ] + else + list_name_lookup_cache = {} + end + %> -
    -
    -

    - <%= content_type_name.pluralize %> -

    +
    +
    +
    + <%= content_type.icon %>
    -
    -
    -
    -
      - <% content_list.each do |content| %> -
    • +

      <%= content_type_name.pluralize %>

      +
    + +
    +
      + <% content_list.each do |content| %> +
    • +
      +
      +
      <%= link_to content do %> - <%= content.class.icon %> +
      + <%= content_type.icon %> +
      <% end %> - +
      +
      +

      <%= (content.respond_to?(:label) ? content.label : list_name_lookup_cache[content.id].presence || content.name) %> - -
      -

      " style="font-size: 80%"> - delete - deleted <%= time_ago_in_words content.deleted_at %> ago -

      -

      " style="font-size: 80%"> - alarm - recoverable for the next <%= distance_of_time_in_words(DateTime.current, content.deleted_at + @maximum_recovery_time) %> -

      - <% if content.respond_to?(:image_uploads) %> -

      - image - <%= pluralize content.image_uploads.count, 'uploaded image' %> -

      - <% end %> -
      -
      -
      - <%= form_for content, html: { style: 'float: left' } do |f| %> - <%= f.hidden_field :deleted_at, value: nil %> - <%= - f.submit 'Recover page', - class: "#{content_class_from_name(content_type_name).color} lighten-4 btn black-text tooltipped", - data: { tooltip: "Un-delete and add this page back to your notebook." } - %> - <% end %> - <%= form_for content do |f| %> - <%= f.hidden_field :deleted_at, value: "1/1 1970".to_date %> - <%= - f.submit 'Delete immediately', - class: 'white btn black-text tooltipped', - data: { tooltip: "Delete this page immediately and remove it from your recycle bin." } - %> +

      +
      +
      + delete + Deleted <%= time_ago_in_words content.deleted_at %> ago +
      + + <% + recovery_deadline = content.deleted_at + @maximum_recovery_time + time_remaining = recovery_deadline - DateTime.current + days_remaining = (time_remaining / 1.day).floor + hours_remaining = ((time_remaining % 1.day) / 1.hour).floor + + # Determine urgency level for color coding + urgency_color = if days_remaining < 1 && hours_remaining < 6 + "text-red-600 dark:text-red-400" + elsif days_remaining < 1 + "text-orange-500 dark:text-orange-400" + else + "text-gray-500 dark:text-gray-400" + end + %> + +
      + alarm + <% if days_remaining > 0 %> + Recoverable for <%= pluralize(days_remaining, 'day') %><%= hours_remaining > 0 ? " and #{pluralize(hours_remaining, 'hour')}" : "" %> + <% else %> + Recoverable for <%= pluralize(hours_remaining, 'hour') %> + <% end %> +
      + + <% if content.respond_to?(:image_uploads) && content.image_uploads.any? %> +
      + image + <%= pluralize content.image_uploads.count, 'uploaded image' %> + (<%= (content.image_uploads.sum(:src_file_size) / 1000.0).round(1) %> KB) +
      <% end %>
      -
      -
    • - <% end %> - -
    -
    -
    -
    +
    +
    + +
    + <%= form_for content do |f| %> + <%= f.hidden_field :deleted_at, value: nil %> + + <% end %> + + <%= form_for content do |f| %> + <%= f.hidden_field :deleted_at, value: "1/1 1970".to_date %> + + <% end %> +
    +
    + + <% end %> +
    - <% end %> +
    + <% end %> - <% if !showed_any_content %> -

    + <% if !showed_any_content %> +

    +
    + delete_outline +
    +

    No Recently Deleted Content

    +

    Looks like you haven't deleted any pages recently. If you do, they will show up here for a limited time.

    - <% end %> -
    +
    + <% end %>
    diff --git a/app/views/content/display/_card.html.erb b/app/views/content/display/_card.html.erb new file mode 100644 index 000000000..b66c111df --- /dev/null +++ b/app/views/content/display/_card.html.erb @@ -0,0 +1,100 @@ +<% + # safely handle nil content_page + return unless local_assigns[:content_page] + + # Default values for locals + show_badges = local_assigns.fetch(:show_badges, true) + show_stats = local_assigns.fetch(:show_stats, true) + show_footer = local_assigns.fetch(:show_footer, true) + action = local_assigns.fetch(:action, nil) + show_private_images = local_assigns.fetch(:show_private_images, false) + + # Determine class for color/icon + # If content_page is a content_type string (e.g. from keys), we can't use it directly. + # We assume content_page is an actual ActiveRecord model instance (Page, Character, etc). + + # Fallback for older patterns where badges might look different + page_type_klass = content_page.class + page_type_name = content_page.class.name + + # Handle special case where content_page might be a hash or different object if not standard + # But based on usage, it seems to be Active Record objects. +%> + +
    + <%= link_to send("edit_#{content_page.page_type.downcase}_path", content_page.id), class: "block h-full" do %> +
    + + +
    + <% + card_image_url = nil + if show_private_images && content_page.respond_to?(:random_image_including_private) + card_image_url = content_page.random_image_including_private + elsif content_page.respond_to?(:first_public_image) + card_image_url = content_page.first_public_image + end + %> + <% if card_image_url.present? %> + <%= image_tag card_image_url, class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %> + <% else %> + <%= image_tag "card-headers/#{content_page.try(:page_type)&.downcase&.pluralize || 'universes'}.webp", + class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300", + alt: "placeholder" %> + <% end %> + + + + +
    + + <% if show_badges %> + +
    + <%= content_page.icon %> + <%= content_page.page_type %> +
    + + <% if action.present? %> +
    + <%= action == 'created' ? 'add_circle' : 'edit' %> + <%= action.titleize %> +
    + <% elsif content_page.respond_to?(:favorite?) && content_page.favorite? %> +
    + star +
    + <% end %> + <% end %> + + +
    +

    + <%= content_page.name %> +

    +
    +
    + + + <% if show_footer %> +
    +
    + + schedule + <%= time_ago_in_words(content_page.updated_at) %> ago + + + <% if show_stats %> + <% if content_page.respond_to?(:cached_word_count) && content_page.cached_word_count && content_page.cached_word_count > 0 %> + + description + <%= number_with_delimiter content_page.cached_word_count %> + + <% end %> + <% end %> +
    +
    + <% end %> +
    + <% end %> +
    diff --git a/app/views/content/display/_contributors.html.erb b/app/views/content/display/_contributors.html.erb index bb717bac6..ce18476fb 100644 --- a/app/views/content/display/_contributors.html.erb +++ b/app/views/content/display/_contributors.html.erb @@ -4,24 +4,32 @@ raw_model = content.is_a?(Universe) ? content : content.raw_model %> -
    +
    <% if raw_model.contributors.any? %> -
      -
    • - <%= User.icon %> - Owner -
    • -
    • - <%= User.icon %> - - <%= link_to(content.user.name, content.user) %> - -

      - Created universe <%= time_ago_in_words content.created_at %> ago -

      - <%= User.icon %> -
    • -
    +
    +
    + <%= User.icon %> + Owner +
    +
    +
    +
    + <%= User.icon %> +
    +
    +
    +

    + <%= link_to(content.user.name, content.user, class: "hover:underline") %> +

    +

    + Created universe <%= time_ago_in_words content.created_at %> ago +

    +
    +
    + <%= User.icon %> +
    +
    +
    <% end %> <%= render partial: 'content/display/contributors_user_list', locals: { content: content, raw_model: raw_model } %> diff --git a/app/views/content/display/_contributors_user_list.html.erb b/app/views/content/display/_contributors_user_list.html.erb index a4a07998a..51aaeaf65 100644 --- a/app/views/content/display/_contributors_user_list.html.erb +++ b/app/views/content/display/_contributors_user_list.html.erb @@ -1,36 +1,49 @@ <%# Usage: render partial: 'content/display/contributors_user_list', locals: { content: content } %> <% if raw_model.contributors.any? %> -
      -
    • - group_add - Contributors -
    • - <% raw_model.contributors.each do |contributor| %> - <%# Don't expose email addresses to anyone other than the content owner who entered them in the first place %> - <% next if contributor.user.nil? && (content.user != current_user)%> +
      +
      + group_add + Contributors +
      +
      + <% raw_model.contributors.each do |contributor| %> + <%# Don't expose email addresses to anyone other than the content owner who entered them in the first place %> + <% next if contributor.user.nil? && (content.user != current_user)%> -
    • - <%= User.icon %> - - <%= contributor.user ? link_to(contributor.user.name, contributor.user) : "#{contributor.email} (invited)" %> - -

      - Invited <%= time_ago_in_words contributor.created_at %> ago -

      - <% if user_signed_in? && content.user == current_user %> -

      - <%= link_to 'Remove this contributor', remove_contributor_path(contributor.id), - class: 'js-remove-contributor', - method: 'delete', - remote: true, - data: { confirm: "Are you sure? They will no longer have contributor access to this universe." } %> -

      - <% end %> - group -
    • - <% end %> -
    +
    +
    +
    + <%= User.icon %> +
    +
    +
    +

    + <%= contributor.user ? link_to(contributor.user.name, contributor.user, class: "hover:underline") : "#{contributor.email} (invited)" %> +

    +

    + Invited <%= time_ago_in_words contributor.created_at %> ago +

    + <% if user_signed_in? && content.user == current_user %> +
    + <%= link_to remove_contributor_path(contributor.id), + class: 'js-remove-contributor text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 flex items-center', + method: 'delete', + remote: true, + data: { confirm: "Are you sure? They will no longer have contributor access to this universe." } do %> + person_remove + Remove this contributor + <% end %> +
    + <% end %> +
    +
    + group +
    +
    + <% end %> +
    +
    <%= render partial: 'content/form/contributors/leave', locals: { content: raw_model } %> <% end %> \ No newline at end of file diff --git a/app/views/content/display/_pagination.html.erb b/app/views/content/display/_pagination.html.erb new file mode 100644 index 000000000..ea3b72330 --- /dev/null +++ b/app/views/content/display/_pagination.html.erb @@ -0,0 +1,86 @@ +
    +
    +
    + + <% if (@current_page || 1) > 1 %> + <%= link_to url_for(params.permit(:sort, :slug, :favorite_only).merge({ page: (@current_page || 1) - 1 })), class: "relative inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" do %> + Previous + <% end %> + <% else %> + + Previous + + <% end %> + + <% if (@current_page || 1) < (@total_pages || 1) %> + <%= link_to url_for(params.permit(:sort, :slug, :favorite_only).merge({ page: (@current_page || 1) + 1 })), class: "ml-3 relative inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" do %> + Next + <% end %> + <% else %> + + Next + + <% end %> +
    + + +
    +
    diff --git a/app/views/content/display/_sideactions.html.erb b/app/views/content/display/_sideactions.html.erb deleted file mode 100644 index d0d7ec101..000000000 --- a/app/views/content/display/_sideactions.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing -%> - -<% - raw_model = content.is_a?(ContentSerializer) ? content.raw_model : content -%> - -<% if user_signed_in? && current_user.can_delete?(raw_model) %> - <%= render partial: 'content/display/sidebar/apps', locals: { content: content, creating: creating, editing: editing } %> - <%= render partial: 'content/display/sidebar/actions', locals: { content: content, creating: creating, editing: editing } %> -<% end %> diff --git a/app/views/content/display/_sidelinks.html.erb b/app/views/content/display/_sidelinks.html.erb deleted file mode 100644 index 498ef23db..000000000 --- a/app/views/content/display/_sidelinks.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing -%> - -<%= render partial: 'content/display/sidebar/categories', locals: { content: content, creating: creating, editing: editing } %> -<%= render partial: 'content/display/sidebar/references', locals: { content: content, creating: creating, editing: editing } %> - -<% if editing %> - <%= render partial: 'content/display/sidebar/more', locals: { content: content, creating: creating, editing: editing } %> -<% end %> diff --git a/app/views/content/display/_tailwind_foldered_index.html.erb b/app/views/content/display/_tailwind_foldered_index.html.erb new file mode 100644 index 000000000..ae5ffbae7 --- /dev/null +++ b/app/views/content/display/_tailwind_foldered_index.html.erb @@ -0,0 +1,920 @@ +<% + if mixed_content_types + content_counts_per_type = { content_type_name: content.count } + else + content_counts_per_type = Hash.new(0) + content.each { |page| content_counts_per_type[page.is_a?(ContentPage) ? page['page_type'] : page.class.name] += 1 } + end +%> + +
    + +
    + +
    +
    + <%= image_tag asset_path(header_image), class: 'h-32 w-full object-cover lg:h-48' %> +
    + +
    +
    +
    +
    + <% if @universe_scope %> + <%= link_to @universe_scope do %> + <%= image_tag @universe_scope.random_image_including_private(format: :hero), class: 'h-16 w-24 rounded-xl ring-4 ring-white shadow-lg lg:h-20 lg:w-32 object-cover' %> + <% end %> + <% else %> +
    + <%= content_type_class.icon %> +
    + <% end %> +
    + +
    +

    + <%= content_type_name.pluralize %> +

    +

    + <% if @universe_scope %> + in <%= link_to @universe_scope.name, @universe_scope, class: "#{Universe.text_color} font-medium hover:underline" %> + <% else %> + by <%= link_to current_user.display_name, current_user, class: "#{User.text_color} font-medium hover:underline" %> + <% end %> +

    +
    +
    + +
    + <% if @current_user_content.fetch(content_type_name, []).any? %> + <% if content.any? %> + +
    + + +
    + <% else %> + + <%= link_to attribute_customization_path(content_type_name.downcase) do %> + + <% end %> + <% end %> + <% end %> + + <% if current_user.can_create?(content_type_class) %> + <%= link_to new_polymorphic_path(content_type_class) do %> + + <% end %> + <% else %> + <%= link_to subscription_path do %> + + <% end %> + <% end %> +
    +
    +
    +
    + + + <% if @questioned_content && @attribute_field_to_question %> + <% serendipitous_category = @attribute_field_to_question.attribute_category %> + + + +
    + + + + + +
    + <% end %> + + +
    +
    +
    + +
    +
    + search +
    + +
    + +
    + + + +
    + + +
    + + +
    + + + <% if true # Always show tag filter so users can start tagging %> +
    + <%= form_with url: "", method: :get, local: true, class: "contents" do |form| %> + <%= hidden_field_tag :sort, params[:sort] if params[:sort].present? %> + + + <% end %> +
    + <% end %> +
    + +
    + + + + +
    +
    + + + <% if @filtered_page_tags && @filtered_page_tags.any? %> +
    + Filtered by: + <% @filtered_page_tags.each do |page_tag| %> + + label + <%= page_tag.tag %> + <%= link_to(polymorphic_path(content_type_class, params.permit(:sort, :favorite_only).merge({ slug: Array(params.fetch(:slug, [])) - [page_tag.slug] }))) do %> + + <% end %> + + <% end %> +
    + <% end %> +
    + + + <% if (@total_pages || 1) > 1 %> + <%= render 'content/display/pagination' %> + <% end %> + + +
    + <% if content.any? %> + + + + +
    +
    + <% content.each do |item| %> +
    +
    + + <%= link_to (item.is_a?(ContentPage) ? item.view_path : item), class: "block" do %> + <% + image_cache = @random_image_including_private_pool_cache.fetch([item.page_type, item.id], []) + item_image = nil + + if image_cache.present? + sample_image = image_cache.sample + if sample_image && sample_image.respond_to?(:src_file_name) && sample_image.src_file_name.present? + item_image = sample_image.src(:medium) + end + end + + if item_image.nil? && @saved_basil_commissions + basil_cache = @saved_basil_commissions.fetch([item.page_type, item.id], []) + if basil_cache.present? + sample_commission = basil_cache.sample + if sample_commission && sample_commission.image.attached? + item_image = sample_commission.image.url + end + end + end + + item_image ||= asset_path("card-headers/#{item.page_type.downcase.pluralize}.jpg") + %> + <%= image_tag item_image, class: 'h-48 w-full object-cover' %> + <% end %> + + +
    +
    +
    +

    + <%= item.page_type %> +

    +

    + <%= item.name %> +

    +
    +
    + + +
    + <%= link_to (item.respond_to?(:view_path) ? item.view_path : item), class: "flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium rounded-lg text-white #{item.color} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all" do %> + visibility + View + <% end %> + <%= link_to (item.respond_to?(:edit_path) ? item.edit_path : edit_polymorphic_path(item)), class: "flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium rounded-lg text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all" do %> + edit + Edit + <% end %> +
    +
    +
    + + + <% if item.respond_to?(:favorite?) %> + + <% end %> +
    + <% end %> + + + <% if @current_user_content.fetch(content_type_name, []).any? %> +
    + <%= link_to attribute_customization_path(content_type_name.downcase), class: "block" do %> +
    +
    +
    +
    +
    + tune +
    +
    Template
    +
    +
    +
    +
    +

    + Customize +

    +

    + Template +

    +
    +
    +
    + <% end %> +
    + <% end %> + + + <% if current_user.can_create?(content_type_class) %> +
    + <%= link_to new_polymorphic_path(content_type_class), class: "block" do %> +
    +
    +
    +
    + add +
    +
    Create
    +
    +
    +
    +
    +

    + New +

    +

    + <%= content_type_name %> +

    +
    +
    +
    + <% end %> +
    + <% end %> +
    +
    + + + + <% else %> + +
    +
    + +
    +
    + <%= content_type_class.icon %> +
    +

    Welcome to your <%= content_type_name.pluralize %>

    +

    + Ready to start building your world? Choose how you'd like to begin your <%= content_type_name.downcase %> collection. +

    +
    + + +
    + +
    + <%= link_to attribute_customization_path(content_type_name.downcase), class: "block h-full" do %> +
    +
    +
    +
    +
    + tune +
    +
    Template
    +
    +
    +
    +
    +

    + Customize +

    +

    + Template +

    +
    +
    +
    + <% end %> +
    + + + <% if current_user.can_create?(content_type_class) %> +
    + <%= link_to new_polymorphic_path(content_type_class), class: "block h-full" do %> +
    +
    +
    +
    +
    + <%= content_type_class.icon %> +
    +
    Create
    +
    +
    +
    +
    +

    + New +

    +

    + <%= content_type_name %> +

    +
    +
    +
    + <% end %> +
    + <% else %> +
    + <%= link_to subscription_path, class: "block h-full" do %> +
    +
    +
    +
    +
    + star +
    +
    Premium
    +
    +
    +
    +
    +

    + Upgrade +

    +

    + Premium +

    +
    +
    +
    + <% end %> +
    + <% end %> +
    +
    +
    + <% end %> + + + <% if (@total_pages || 1) > 1 %> + <%= render 'content/display/pagination' %> + <% end %> + + <% if @universe_scope.present? %> +
    + <%= render 'shared/universe_filter_reminder', content_type_name: content_type_name %> +
    + <% end %> +
    +
    + + + +
    \ No newline at end of file diff --git a/app/views/content/display/sidebar/_actions.html.erb b/app/views/content/display/sidebar/_actions.html.erb deleted file mode 100644 index a58eb5945..000000000 --- a/app/views/content/display/sidebar/_actions.html.erb +++ /dev/null @@ -1,90 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing -%> - -<% - raw_model = content.is_a?(ContentSerializer) ? content.raw_model : content -%> - -
      -
    • - Actions -
    • - -
    • - <%= link_to '#/', class: "expand" do %> - format_line_spacing - Expand all categories - <% end %> -
    • - - <% if editing %> -
    • - <%= link_to raw_model do %> - <%= raw_model.class.icon %> - View this <%= content.class_name.downcase %> - <% end %> -
    • - <% end %> - - <% if raw_model.persisted? %> -
    • - <%= link_to '#/', class: 'share' do %> - share - Share this <%= content.class_name.downcase %> - <% end %> -
    • - <% end %> - - <% if user_signed_in? && current_user.id == content.user.id %> -
    • - <%= link_to attribute_customization_path(content_type: raw_model.class.name.downcase) do %> - tune - Configure <%= raw_model.class.name.downcase %> fields - <% end %> -
    • - <% end %> - - <% if raw_model.persisted? %> -
    • - <%= link_to send("gallery_#{raw_model.class.name.downcase}_path", raw_model) do %> - photo_library - View full gallery - <% end %> -
    • - -
    • - <%= link_to send("changelog_#{raw_model.class.name.downcase}_path", raw_model) do %> - history - View changelog - <% end %> -
    • - -
    • - <%= - link_to send("toggle_archive_#{raw_model.class.name.downcase}_path", raw_model), - data: { - confirm: raw_model.archived? ? "This will un-archive this page." : "Are you sure you want to archive this #{raw_model.class.name.downcase}?", - } do - %> - <%= 'un' if raw_model.archived? %>archive - <%= raw_model.archived? ? 'Un-archive' : 'Archive' %> this page - <% end %> -
    • - - <% if editing %> -
    • - <%= - link_to raw_model, - method: :delete, - data: { - confirm: "Are you sure? This will delete this entire #{raw_model.class.name.downcase}!", - } do %> - delete - Delete this page - <% end %> -
    • - <% end %> - <% end %> -
    diff --git a/app/views/content/display/sidebar/_apps.html.erb b/app/views/content/display/sidebar/_apps.html.erb deleted file mode 100644 index 519f642bf..000000000 --- a/app/views/content/display/sidebar/_apps.html.erb +++ /dev/null @@ -1,34 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing - show_basil_tool = BasilService::ENABLED_PAGE_TYPES.include? content.class_name - show_conversation = false && content.class_name == 'Character' - - show_tools_menu = show_basil_tool || show_conversation -%> - -<% if show_tools_menu %> -
      -
    • - Tools -
    • - - <% if show_basil_tool %> -
    • - <%= link_to basil_content_path(content_type: content.class_name, id: content.id) do %> - palette - Image Generation - <% end %> -
    • - <% end %> - - <% if show_conversation %> -
    • - <%= link_to talk_path(character_id: content.id) do %> - message - Talk to <%= content.name %> - <% end %> -
    • - <% end %> -
    -<% end %> \ No newline at end of file diff --git a/app/views/content/display/sidebar/_categories.html.erb b/app/views/content/display/sidebar/_categories.html.erb deleted file mode 100644 index 25e4c3a6f..000000000 --- a/app/views/content/display/sidebar/_categories.html.erb +++ /dev/null @@ -1,61 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing -%> - -<% - raw_model = content.is_a?(ContentSerializer) ? content.raw_model : content -%> - - \ No newline at end of file diff --git a/app/views/content/display/sidebar/_more.html.erb b/app/views/content/display/sidebar/_more.html.erb deleted file mode 100644 index 0f82dd783..000000000 --- a/app/views/content/display/sidebar/_more.html.erb +++ /dev/null @@ -1,45 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing -%> - -<% - raw_model = content.is_a?(ContentSerializer) ? content.raw_model : content -%> - -
      -
    • - More -
    • - -
    • - <%= link_to ForumsLinkbuilderService.worldbuilding_url(raw_model.class) do %> - forum - Discuss <%= raw_model.class.name.downcase.pluralize %> - <% end %> -
    • - -
    • - <%= link_to main_app.new_polymorphic_path(raw_model.class) do %> - add - Create a <%= raw_model.class.name.downcase %> - <% end %> -
    • - - <% if user_signed_in? && current_user.id == raw_model.user_id && false %> -
    • - <%= link_to main_app.polymorphic_path(raw_model.class) do %> - <%= raw_model.class.icon %> - Your <%= raw_model.class.name.downcase.pluralize %> - <%= @current_user_content.fetch(raw_model.class.name, []).count %> - <% end %> -
    • - <% else %> -
    • - <%= link_to send("#{raw_model.class.name.downcase.pluralize}_user_path", { id: raw_model.user_id }) do %> - <%= raw_model.class.icon %> - <%= raw_model.class.name.pluralize %> by <%= editing ? current_user.display_name : raw_model.user.display_name %> - <% end %> -
    • - <% end %> -
    diff --git a/app/views/content/display/sidebar/_references.html.erb b/app/views/content/display/sidebar/_references.html.erb deleted file mode 100644 index d97d75357..000000000 --- a/app/views/content/display/sidebar/_references.html.erb +++ /dev/null @@ -1,109 +0,0 @@ -<% - creating = defined?(creating) && creating - editing = defined?(editing) && editing -%> - -<% - raw_model = content.is_a?(ContentSerializer) ? content.raw_model : content -%> - -<% related_documents = content.documents.select { |doc| (current_user || User.new).can_read?(doc) } %> - -<% - show_in_this_universe_tab = !creating && !editing && raw_model.is_a?(Universe) - show_associations_tab = !creating && !editing - show_gallery_tab = creating || editing || raw_model.image_uploads.any? || @basil_images.any? - show_documents_tab = !creating && !editing && related_documents.any? - show_shares_tab = !creating && !editing && raw_model.content_page_shares.any? - show_collections_tab = !creating && !editing && raw_model.page_collection_submissions.accepted.any? - show_timelines_tab = !creating && !editing && raw_model.timelines.any? && user_signed_in? && current_user == raw_model.user - - show_references_section = [ - show_in_this_universe_tab, - show_associations_tab, - show_gallery_tab, - show_documents_tab, - show_shares_tab, - show_collections_tab, - show_timelines_tab - ].any? -%> - -<% if show_references_section %> -
    -<% end %> \ No newline at end of file diff --git a/app/views/content/display/tailwind_content_list/_card_index.html.erb b/app/views/content/display/tailwind_content_list/_card_index.html.erb new file mode 100644 index 000000000..a2338f91f --- /dev/null +++ b/app/views/content/display/tailwind_content_list/_card_index.html.erb @@ -0,0 +1,34 @@ +
    + <% content.each do |content| %> +
    +
    + + <%= link_to (content.respond_to?(:view_path) ? content.view_path : content), class: "block" do %> + <%= image_tag content.random_image_including_private(format: :small), class: 'h-64 w-full object-cover object-center' %> + <% end %> + + +
    +

    + <%= content.page_type %> +

    +

    + <%= content.name %> +

    + + +
    + <%= link_to (content.respond_to?(:view_path) ? content.view_path : content), class: "flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium rounded-lg text-white #{content.color} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all" do %> + visibility + View + <% end %> + <%= link_to (content.respond_to?(:edit_path) ? content.edit_path : edit_polymorphic_path(content)), class: "flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-medium rounded-lg text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all" do %> + edit + Edit + <% end %> +
    +
    +
    +
    + <% end %> +
    \ No newline at end of file diff --git a/app/views/content/display/tailwind_content_list/_document_index.html.erb b/app/views/content/display/tailwind_content_list/_document_index.html.erb new file mode 100644 index 000000000..466eb165a --- /dev/null +++ b/app/views/content/display/tailwind_content_list/_document_index.html.erb @@ -0,0 +1,75 @@ +
    +
    +
    +
    +
    + + + + + + + + + + + + <% content.each do |document| %> + + + + + + + + <% end %> + +
    TitleSynopsisLengthActions
    + <%= Document.icon %> + + Draft + + +
    +
    +
    <%= document.title %>
    +
    + by <%= link_to document.user.display_name, document.user, class: User.text_color %> +
    +
    +
    +
    +

    + <% if document.synopsis.blank? %> + None + <% else %> + <%= document.synopsis %> + <% end %> +

    +
    + <% document.page_tags.each do |tag| %> + + <%= link_to params.permit(:tag).merge({ tag: PageTagService.slug_for(tag.tag) }) do %> + <%= tag.tag %> + <% end %> + + <% end %> +
    +
    +
    + translate + <%= number_with_delimiter document.cached_word_count || 0 %> words +
    +
    + timer + <%= document.reading_estimate %> +
    +
    + <%= link_to 'View', document_path(document), class: 'block bg-green-100 mb-1 p-1 font-medium text-green-800 hover:text-white hover:bg-teal-600 rounded border border-green-200' %> + <%= link_to 'Edit', edit_document_path(document), class: 'block bg-green-100 mb-1 p-1 font-medium text-green-800 hover:text-white hover:bg-teal-600 rounded border border-green-200' %> +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/app/views/content/edit.html.erb b/app/views/content/edit.html.erb index ba36c3f48..786844ab5 100644 --- a/app/views/content/edit.html.erb +++ b/app/views/content/edit.html.erb @@ -1,54 +1,713 @@ -<% set_meta_tags title: "Editing " + @content.name, description: @content.description %> - -<%= content_for :full_width_page_header do %> - <%= render partial: 'content/display/image_card_header' %> -<% end %> - -
    -
    -
    - <%= - render partial: 'content/display/sidelinks', - locals: { - editing: true, - content: @serialized_content - } - %> -
    -
    +<% + page_description = "Editing #{@content.name} — a fictional #{@content.class.name.downcase} on Notebook.ai" + set_meta_tags title: "Edit #{@content.name}", + description: page_description.truncate(160) +%> -
    - <%= render partial: 'content/form', locals: { content: @serialized_content } %> -
    +
    + + + <%= render partial: 'javascripts/content_linking_alpine' %> + + + <%= render partial: 'content/header', locals: { + content: @serialized_content, + show_edit_controls: true, + current_page: :edit + } %> -
    -
    - <%= - render partial: 'content/display/sideactions', - locals: { - editing: true, - content: @serialized_content - } - %> + + <%= render partial: 'content/hero_header', locals: { + content: @content, + serialized_content: @serialized_content, + height_classes: 'h-32 md:h-40 lg:h-48' + } %> + + + +
    +
    + + + + + + + + +
    + + +
    + +
    + + <%= render partial: 'content/edit/navigation_sidebar', locals: { + content: @serialized_content, + raw_content: @content + } %> +
    + + + + + +
    + +
    + + +
    + <%= render partial: 'content/edit/dynamic_content', locals: { + content: @serialized_content, + raw_content: @content + } %> +
    + + + + + + + + +
    + +
    + + + + + + +
    -<%# todo: surely we can strip this out (to a general slider.js?), no? %> - -<%= render partial: 'javascripts/content_linking' %> + + + init() { + // Initialize right sidebar state from localStorage + // Left sidebar is already initialized in the property definition + this.initializeSidebarState(); -<% if @content.persisted? %> - <%= render partial: 'content/share', locals: { shared_content: @content} %> -<% end %> + // Set initial view from URL hash if present + const hash = window.location.hash.substring(1); + if (['details', 'gallery', 'privacy', 'contributors', 'universe', 'settings'].includes(hash)) { + this.currentView = hash; + } + + // Track unsaved changes (but not for file inputs) + document.addEventListener('input', (e) => { + // Skip file inputs - they don't need unsaved changes tracking + if (e.target.type === 'file') { + return; + } + + // Skip image notes textareas - they have their own save mechanism + if (e.target.classList.contains('js-image-notes')) { + return; + } + + if (e.target.closest('form')) { + this.hasUnsavedChanges = true; + } + }); + + // Clear unsaved changes flag when autosave succeeds and update last saved time + document.addEventListener('autosave:success', () => { + this.hasUnsavedChanges = false; + this.updateLastSavedTime(); + }); + + // Warn user about unsaved changes when leaving page + window.addEventListener('beforeunload', (e) => { + if (this.hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; + return 'You have unsaved changes. Are you sure you want to leave?'; + } + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Ctrl/Cmd + S to save + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + const activeElement = document.activeElement; + if (activeElement && activeElement.classList.contains('js-autosave')) { + activeElement.blur(); // Trigger autosave + } + } + + // Escape to go back to view mode + if (e.key === 'Escape' && !e.target.closest('.dropdown-menu')) { + e.preventDefault(); + if (confirm('Are you sure you want to stop editing? Any unsaved changes will be lost.')) { + window.location.href = '<%= polymorphic_path(@content) %>'; + } + } + }); + + // Handle window resize + window.addEventListener('resize', () => { + // Left Sidebar Logic (lg breakpoint) + this.isDesktop = window.innerWidth >= 1024; + this.showLeftSidebar = window.innerWidth >= 1024; + + // Right Sidebar Logic (lg breakpoint) + const wasXlDesktop = this.isXlDesktop; + this.isXlDesktop = window.innerWidth >= 1024; + + // When transitioning from mobile to desktop (lg), restore saved right sidebar state + if (!wasXlDesktop && this.isXlDesktop) { + this.showRightSidebar = this.getSavedSidebarState(); + } + // When transitioning from desktop (lg) to mobile, always close right sidebar + if (wasXlDesktop && !this.isXlDesktop) { + this.showRightSidebar = false; + } + }); + + // Listen for universe field changes and update Privacy tab + this.watchUniverseChanges(); + + // Listen for privacy updates from the privacy form + window.addEventListener('privacy-updated', (e) => { + this.selectedPrivacy = e.detail.privacy; + }); + }, + + watchUniverseChanges() { + const self = this; + + // Watch for changes on universe select field + document.addEventListener('change', function(e) { + if (e.target.classList.contains('universe-select-field') || e.target.dataset.universeSelect) { + const universeId = e.target.value; + const selectedOption = e.target.options[e.target.selectedIndex]; + const universeName = selectedOption ? selectedOption.text : ''; + const universePrivacy = selectedOption ? selectedOption.dataset.universePrivacy : 'private'; + + // Update the Alpine.js reactive state + self.universePrivacy = universePrivacy; + + // Update both the Privacy tab and sidebar Universe sections + self.updatePrivacyUniverseSection(universeId, universeName, universePrivacy); + self.updateSidebarUniverseSection(universeId, universeName); + } + }); + + // Also watch for successful autosave to ensure we catch the update + document.addEventListener('ajax:success', function(e) { + // Update last saved time for any successful AJAX save + self.updateLastSavedTime(); + + if (e.target && e.target.querySelector && e.target.querySelector('.universe-select-field')) { + const select = e.target.querySelector('.universe-select-field'); + if (select) { + const universeId = select.value; + const selectedOption = select.options[select.selectedIndex]; + const universeName = selectedOption ? selectedOption.text : ''; + const universePrivacy = selectedOption ? selectedOption.dataset.universePrivacy : 'private'; + + // Update the Alpine.js reactive state + self.universePrivacy = universePrivacy; + + self.updatePrivacyUniverseSection(universeId, universeName, universePrivacy); + self.updateSidebarUniverseSection(universeId, universeName); + } + } + }); + }, + + updatePrivacyUniverseSection(universeId, universeName, universePrivacy) { + const privacyUniverseSection = document.querySelector('[data-privacy-universe-section]'); + if (!privacyUniverseSection) { + return; + } + + + if (!universeId || universeId === '' || universeId === 'null') { + // Show "No Universe Assigned" state + this.showNoUniverseState(privacyUniverseSection); + } else { + // Show universe state without fetching + this.showUniverseState(privacyUniverseSection, universeId, universeName, universePrivacy); + } + }, + + showNoUniverseState(section) { + // Find and update the content area + const contentArea = section.querySelector('[data-universe-content]'); + if (contentArea) { + const firstCategoryLabel = '<%= @serialized_content.data[:categories].first[:label] rescue "Overview" %>'; + contentArea.innerHTML = ` +
    +
    +
    +
    + language +
    +
    +
    +
    No Universe Assigned
    +
    + This <%= @serialized_content.class_name.downcase %> isn't part of a universe yet. +
    +
    +
    Benefits of using universes:
    +
      +
    • + check_circle + Control privacy for all pages at once +
    • +
    • + check_circle + Organize related content together +
    • +
    • + check_circle + Share entire worlds with collaborators +
    • +
    +
    +
    +
    + +
    +
    +
    + `; + } + }, + + showUniverseState(section, universeId, universeName, universePrivacy) { + const contentArea = section.querySelector('[data-universe-content]'); + if (!contentArea) { + return; + } + + const isPublic = universePrivacy === 'public'; + const displayName = universeName || 'Selected Universe'; + + contentArea.innerHTML = ` +
    + `; + }, + + updateSidebarUniverseSection(universeId, universeName) { + const sidebarSection = document.querySelector('[data-sidebar-universe-section]'); + if (!sidebarSection) { + return; + } + + const contentArea = sidebarSection.querySelector('[data-sidebar-universe-content]'); + if (!contentArea) { + return; + } + + + if (!universeId || universeId === '' || universeId === 'null') { + // Show "No universe assigned" state + contentArea.innerHTML = ` +
    +
    + <%= Universe.icon %> +
    +

    No universe assigned

    +
    + `; + } else { + // Show universe card with image + const displayName = universeName || 'Selected Universe'; + + // We'll try to load the default universe card header image + // The actual universe image would require a fetch, but we can use the default fallback + contentArea.innerHTML = ` + + + ${displayName} preview + + +
    +
    +
    +

    + ${displayName} +

    +

    + <%= Universe.icon %> + Universe +

    +
    + open_in_new +
    +
    +
    + `; + } + }, + + switchView(view) { + this.currentView = view; + this.currentCategory = null; + this.expandAllCategories = false; + window.location.hash = view; + + // Scroll to top of content area + const contentArea = document.querySelector('.flex-1'); + if (contentArea) contentArea.scrollTop = 0; + }, + + showCategory(categoryName) { + this.currentView = 'details'; + this.currentCategory = categoryName; + this.expandAllCategories = false; + window.location.hash = 'details'; + }, + + showAllCategories() { + this.currentView = 'details'; + this.currentCategory = null; + this.expandAllCategories = true; + window.location.hash = 'details'; + }, + + isViewActive(view) { + return this.currentView === view; + }, + + isCategoryActive(categoryName) { + return this.currentView === 'details' && this.currentCategory === categoryName && !this.expandAllCategories; + }, + + isExpandAllActive() { + return this.currentView === 'details' && this.expandAllCategories; + }, + + initializeSidebarState() { + // On desktop (xl), restore saved preference (default: shown) + // On mobile, always start closed + if (this.isXlDesktop) { + this.showRightSidebar = this.getSavedSidebarState(); + } else { + this.showRightSidebar = false; + } + }, + + getSavedSidebarState() { + try { + const saved = localStorage.getItem('content_edit_right_sidebar_visible'); + // Default to true (shown) if not set + return saved === null ? true : saved === 'true'; + } catch (e) { + // If localStorage is not available, default to shown + return true; + } + }, + + saveSidebarState() { + try { + localStorage.setItem('content_edit_right_sidebar_visible', this.showRightSidebar.toString()); + } catch (e) { + // Silently fail if localStorage is not available + } + }, + + toggleLeftSidebar() { + this.showLeftSidebar = !this.showLeftSidebar; + // No longer saving to localStorage - state is always based on screen size on page load + }, + + toggleRightSidebar() { + this.showRightSidebar = !this.showRightSidebar; + + // Only save preference on desktop (xl) (mobile is always session-based) + if (this.isXlDesktop) { + this.saveSidebarState(); + } + }, + + closeMobileSidebar() { + // Only close if on mobile (< 1024px) + if (!this.isXlDesktop) { + this.showRightSidebar = false; + } + }, + + updateLastSavedTime() { + // Update the "Last saved" time in the left sidebar + const lastSavedElement = document.getElementById('last-saved-time'); + if (lastSavedElement) { + // Show "just now" for very recent saves, otherwise show relative time + lastSavedElement.textContent = 'just now'; + + // After a short delay, you could update it to show "a few seconds ago" + setTimeout(() => { + if (lastSavedElement) { + lastSavedElement.textContent = 'a few seconds ago'; + } + }, 5000); + } + } + } +} + +// Update breadcrumb when name field is saved +document.addEventListener('DOMContentLoaded', function() { + document.addEventListener('autosave:success', function(event) { + // Check if this is a name-type field being saved + const field = event.detail.field; + const fieldContainer = field.closest('[data-field-type]'); + const fieldType = fieldContainer?.dataset.fieldType; + + // Only proceed if this is a name-type field + if (fieldType === 'name') { + // Get the new name from the field value + const newName = field.value?.trim(); + + if (newName && newName !== '' && !newName.startsWith('New ')) { + // Update the breadcrumb link text + const breadcrumbLink = document.querySelector('[data-breadcrumb-name]'); + if (breadcrumbLink) { + breadcrumbLink.textContent = newName; + } + + // Also update the page title + const pageTitle = document.title; + const titleParts = pageTitle.split(' — '); + if (titleParts.length > 0) { + titleParts[0] = 'Edit ' + newName; + document.title = titleParts.join(' — '); + } + + // Update the page header if it exists + const pageHeader = document.querySelector('h1.page-header, .page-title'); + if (pageHeader) { + const headerLink = pageHeader.querySelector('a'); + if (headerLink) { + headerLink.textContent = newName; + } else { + pageHeader.textContent = newName; + } + } + } + } + }); +}); + \ No newline at end of file diff --git a/app/views/content/edit/_category_fields.html.erb b/app/views/content/edit/_category_fields.html.erb new file mode 100644 index 000000000..282220e14 --- /dev/null +++ b/app/views/content/edit/_category_fields.html.erb @@ -0,0 +1,169 @@ +
    + <% category[:fields].each do |field| %> +
    + + +
    + + + <% if field[:help_text].present? %> +

    <%= field[:help_text] %>

    + <% end %> +
    + + +
    + +
    + + + + + Saved + +
    + + <%= form_for raw_content, url: FieldTypeService.form_path(field), remote: true, authenticity_token: true, html: { class: "field-form" } do |f| %> + <%= hidden_field_tag "entity[entity_id]", raw_content.id %> + <%= hidden_field_tag "entity[entity_type]", raw_content.class.name %> + + <%= + case field[:type] + when 'name', 'text_area', 'textarea' + render partial: 'content/form/rich_text_input', locals: { + f: f, + content: content, + field: field, + show_label: false, + autocomplete: AutocompleteService.for_field_label(content_model: raw_content.class, label: field[:label]), + autosave: true + } + when 'universe' + render partial: 'content/form/field_types/universe', locals: { + f: f, + field: field, + page: content, + raw_model: raw_content + } + when 'tags' + render partial: 'content/form/field_types/tags', locals: { + f: f, + field: field, + page: content, + raw_model: raw_content + } + when 'link' + render partial: 'content/form/field_types/link', locals: { + f: f, + field: field, + page: content, + raw_model: raw_content + } + end + %> + <% end %> +
    +
    + <% end %> +
    + + + + diff --git a/app/views/content/edit/_dynamic_content.html.erb b/app/views/content/edit/_dynamic_content.html.erb new file mode 100644 index 000000000..0eb0bd131 --- /dev/null +++ b/app/views/content/edit/_dynamic_content.html.erb @@ -0,0 +1,737 @@ +
    + + +
    + + +
    +

    Edit All Details

    +

    Complete information for this <%= content.class_name.downcase %>

    +
    + + + <% + # Find the first non-Gallery category for default view + first_category_label = content.data[:categories].find { |c| c[:label] != 'Gallery' }&.dig(:label) || '' + %> +
    + <% content.data[:categories].each do |category| %> + <% next if category[:label] == 'Gallery' %> + <% next if category[:label] == 'Contributors' && raw_content.class.name == 'Universe' %> + <% is_first_category = (category[:label] == first_category_label) %> + + +
    + + +
    +
    +
    + <%= category[:icon] %> +
    +

    <%= category[:label] %>

    +

    Edit <%= category[:label] %>

    +

    Update <%= category[:label].downcase %> details for this <%= content.class_name.downcase %>

    +
    +
    + + + <% if category[:percent_complete].present? && category[:percent_complete] > 0 %> + + <% end %> +
    + + + <% if category[:percent_complete].present? && category[:percent_complete] >= 25 %> +
    +
    +
    +
    +
    +

    <%= category[:percent_complete] %>% complete

    +
    + <% end %> +
    + + + <%= render partial: 'content/edit/category_fields', locals: { + content: content, + category: category, + raw_content: raw_content + } %> +
    + <% end %> +
    +
    + + +
    +
    +
    +
    +

    Gallery Management

    +

    Upload and manage images for this <%= content.class_name.downcase %>

    +
    +
    +
    + + <%= form_for raw_content, url: polymorphic_path(raw_content), method: :patch, html: { multipart: true } do |f| %> + <%= render partial: 'content/edit/gallery_panel', locals: { + content: content, + raw_content: raw_content, + f: f + } %> + <% end %> +
    + + +
    +
    +

    Privacy & Sharing

    +

    Manage who can see and access this <%= content.class_name.downcase %>

    +
    + +
    + + +
    + check_circle + +
    + + +
    +
    +
    +
    + + +
    +
    +

    + Current Status +
    + + + + +
    +

    +

    + This <%= content.class_name.downcase %> is currently + + +

    +
    +
    +
    +
    Effective visibility
    + <% if raw_content.respond_to?(:universe) && raw_content.universe.present? && raw_content.universe.privacy == 'public' %> +
    + Public + info +
    +
    via Universe
    + <% else %> +
    +
    + <% end %> +
    +
    +
    + + +
    +
    +

    Privacy Settings

    +

    Choose who can see and access this <%= content.class_name.downcase %>

    +
    + + <%= form_for raw_content, remote: true, html: { id: "privacy-settings-form", class: "space-y-3" } do |f| %> + + + + + + <% end %> + + +
    +

    + tune + Additional Options +

    + +
    + +
    +
    + key +
    +
    +
    Password Protected
    +
    Require a password to view when shared
    +
    + Soon +
    +
    +
    + + +
    +

    + tune + Additional Options +

    + +
    + +
    +
    + search +
    +
    +
    Discoverable
    +
    Appears in public searches and content indexes
    +
    + Soon +
    +
    +
    +
    + + + <% if raw_content.respond_to?(:universe) %> +
    +
    +

    + <%= Universe.icon %> + Universe Privacy +

    + <% if raw_content.universe.present? %> +

    This page belongs to a universe with its own privacy settings

    + <% else %> +

    Organize your pages in a universe to manage privacy settings collectively

    + <% end %> +
    + +
    + <% if raw_content.universe.present? %> + +
    +
    +
    +
    + <%= Universe.icon %> +
    +
    +
    +
    <%= raw_content.universe.name %>
    +
    + Universe is: + + <%= (raw_content.universe.privacy || 'private') == 'public' ? 'public' : 'lock' %> + <%= (raw_content.universe.privacy || 'private').capitalize %> + +
    + + <% if (raw_content.universe.privacy || 'private') == 'public' %> +
    +
    + bolt +
    +
    Universe Override Active
    +
    All pages in this universe are automatically public
    +
    +
    +
    + <% else %> +
    + Individual page privacy settings apply +
    + <% end %> +
    +
    + <%= link_to edit_polymorphic_path(raw_content.universe), + class: "inline-flex items-center px-3 py-1.5 bg-white dark:bg-gray-700 border border-blue-300 dark:border-blue-600 rounded text-xs font-medium text-blue-700 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-800 transition-colors" do %> + settings + Manage + <% end %> +
    +
    +
    + <% else %> + +
    +
    +
    +
    + <%= Universe.icon %> +
    +
    +
    +
    No Universe Assigned
    +
    + This <%= content.class_name.downcase %> isn't part of a universe yet. +
    +
    +
    Benefits of using universes:
    +
      +
    • + check_circle + Control privacy for all pages at once +
    • +
    • + check_circle + Organize related content together +
    • +
    • + check_circle + Share entire worlds with collaborators +
    • +
    +
    +
    +
    + +
    +
    +
    + <% end %> +
    +
    + <% end %> + + + <% if raw_content.class.name == 'Universe' && raw_content.respond_to?(:contributors) %> + +
    +

    Universe Contributors

    +

    + Manage who can collaborate on this universe and all its content +

    + + <% contributor_count = raw_content.contributors.count rescue 0 %> +
    +
    +
    +
    + group + + <%= contributor_count %> <%= 'contributor'.pluralize(contributor_count) %> + +
    +

    + Contributors can edit all content within this universe +

    +
    + +
    +
    +
    + <% elsif raw_content.respond_to?(:contributors) %> + +
    +

    Collaborators

    +

    Collaboration features coming soon

    +
    + <% end %> + + +
    +
    + info + Changes are saved automatically +
    +
    + +
    +
    + + + + <% if raw_content.class.name == 'Universe' %> +
    +
    +

    Universe Contributors

    +

    Manage who can collaborate on this universe and all its content

    +
    + + <%= render partial: 'content/shared/contributors_panel', locals: { + content: content, + raw_content: raw_content + } %> +
    + <% end %> + + +
    +
    +

    Page Settings

    +

    Advanced settings for this <%= content.class_name.downcase %>

    +
    + +
    + +
    ', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').getAttribute('content') + } + }); + const data = await response.json(); + if (data.success) { + this.isArchived = data.archived; + if (window.showToast) { + window.showToast(this.isArchived ? 'Page archived' : 'Page un-archived', 'success'); + } + } else { + if (window.showToast) window.showToast(data.error || 'Something went wrong', 'error'); + } + } catch (error) { + console.error('Error:', error); + if (window.showToast) window.showToast('An error occurred', 'error'); + } finally { + this.isLoading = false; + } + } + }"> +

    Archive Page

    + +
    +

    + this <%= content.class_name.downcase %> +

    +

    + Archiving will hide this page from your main lists and searches. You can restore it anytime from your archives. +

    +

    + This page is currently archived. Un-archive it to make it visible again in your main lists and searches. +

    +
    + + + <%= link_to archive_path, + class: "inline-flex items-center px-4 py-2 text-sm font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-800 dark:hover:text-yellow-200" do %> + restore + View archives + <% end %> +
    +
    +
    + + +
    +

    Danger Zone

    + +
    +

    Delete this <%= content.class_name.downcase %>

    +

    + Once deleted, this page cannot be recovered. All associated data will be permanently removed. +

    + +
    + + + +
    +
    +
    + +
    \ No newline at end of file diff --git a/app/views/content/edit/_edit_main.html.erb b/app/views/content/edit/_edit_main.html.erb new file mode 100644 index 000000000..e2800195f --- /dev/null +++ b/app/views/content/edit/_edit_main.html.erb @@ -0,0 +1,259 @@ +
    +
    + <% content.data[:categories].each do |category| %> + <% next if category[:label] == 'Gallery' %> + +
    +
    +
    +

    + <%= category[:icon] %> + <%= category[:label] %> +

    + + + <% if category[:percent_complete].present? && category[:percent_complete] >= 25 %> +
    +
    + <%= category[:percent_complete] %>% complete +
    +
    + <% end %> +
    + +
    + <% category[:fields].each do |field| %> +
    + + +
    + + + + <% if field[:help_text].present? %> +

    <%= field[:help_text] %>

    + <% end %> +
    + + +
    + <%= form_for content.raw_model, url: FieldTypeService.form_path(field), remote: true, authenticity_token: true, html: { class: "field-form" } do |f| %> + <%= hidden_field_tag "entity[entity_id]", content.id %> + <%= hidden_field_tag "entity[entity_type]", content.class_name %> + + +
    + + + + + Saved + +
    + + <%= + case field[:type] + when 'name', 'text_area', 'textarea' + render partial: 'content/form/rich_text_input', locals: { + f: f, + content: content, + field: field, + show_label: false, + autocomplete: AutocompleteService.for_field_label(content_model: content.class, label: field[:label]), + autosave: true + } + when 'universe' + render partial: 'content/form/field_types/universe', locals: { + f: f, + field: field, + page: content, + raw_model: content.raw_model + } + when 'tags' + render partial: 'content/form/field_types/tags', locals: { + f: f, + field: field, + page: content, + raw_model: content.raw_model + } + when 'link' + render partial: 'content/form/field_types/link', locals: { + f: f, + field: field, + page: content, + raw_model: content.raw_model + } + end + %> + <% end %> +
    +
    + <% end %> +
    +
    + <% end %> +
    +
    + + + + + \ No newline at end of file diff --git a/app/views/content/edit/_edit_tools.html.erb b/app/views/content/edit/_edit_tools.html.erb new file mode 100644 index 000000000..97f35e1d1 --- /dev/null +++ b/app/views/content/edit/_edit_tools.html.erb @@ -0,0 +1,294 @@ + + + + + +
    +
    +
    +
    + Status: + Auto-saving... +
    +
    + Progress: + <%= completed_fields %>/<%= total_fields %> +
    +
    + + +
    + + +
    \ No newline at end of file diff --git a/app/views/content/edit/_gallery_panel.html.erb b/app/views/content/edit/_gallery_panel.html.erb new file mode 100644 index 000000000..33f77418a --- /dev/null +++ b/app/views/content/edit/_gallery_panel.html.erb @@ -0,0 +1,630 @@ +<% + # Get both image types with ordering + regular_images = raw_content.image_uploads.ordered.to_a rescue [] + basil_images = raw_content.basil_commissions.where.not(saved_at: nil).ordered.to_a rescue [] + + unless user_signed_in? && raw_content.user_id == current_user.id + regular_images = regular_images.select { |img| img.privacy == 'public' } rescue [] + end + + # Calculate total images for display purposes + total_images = regular_images.count + basil_images.count + + # Combine and sort images + combined_images = [] + regular_images.each do |img| + combined_images << { + id: img.id, + type: 'image_upload', + data: img, + position: img.position || 999, + pinned: img.pinned == true + } + end + + basil_images.each do |img| + combined_images << { + id: img.id, + type: 'basil_commission', + data: img, + position: img.position || 999, + pinned: img.pinned == true + } + end + + combined_images.sort_by! { |img| img[:position] } +%> + +
    + <% if combined_images.any? %> + +
    +
    +

    + Current Images (<%= total_images %>) +

    + +
    + + +
    + <% end %> + + +
    +

    + file_upload + Upload Images +

    + + <% if current_user.upload_bandwidth_kb > 0 %> +

    + You have <%= Filesize.from("#{current_user.upload_bandwidth_kb}KB").pretty %> of bandwidth remaining. +

    + <% else %> +
    +

    + warning + You have no upload bandwidth remaining. Upgrade to Premium or delete some existing images for more. +

    +
    + <% end %> + +
    + <%= render partial: 'content/form/images/upload', locals: { f: f, content: content } %> + +
    + <%= link_to_add_association "add_photo_alternateAdd another image".html_safe, f, + :image_uploads, + class: 'inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500', + partial: 'content/form/images/upload_fields' + %> + + <%= f.button :submit, + class: 'upload-button inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500', + data: { disable_with: 'cloud_upload Uploading...' } do %> + cloud_upload + Upload images + <% end %> +
    + +

    + Once you've selected your images, press the upload button above. This will reload the page. +

    +
    +
    + + + <% if BasilService::ENABLED_PAGE_TYPES.include?(raw_content.class.name) %> +
    +

    + auto_awesome + Generate with Basil +

    + +

    + Let Basil create unique images for your <%= raw_content.page_type.downcase %> based on the details you've added. +

    + +
    +
    +
    +
    + tips_and_updates +
    +
    +
    +

    + Basil uses the information from your <%= raw_content.page_type.downcase %> page to generate images that match your vision. +

    +

    + The more details you've added about appearance and characteristics, the better the results will be. +

    +
    +
    +
    + + <% if basil_images.any? %> +
    +

    + check_circle + You've already generated <%= pluralize(basil_images.count, 'image') %> with Basil +

    +
    + <% end %> + +
    + <%= link_to basil_content_path(raw_content.page_type.downcase, raw_content.id), + class: 'inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all group' do %> + auto_awesome + Generate New Image + arrow_forward + <% end %> + + <% if current_user && !current_user.on_premium_plan? %> +
    + <% generated_count = current_user.basil_commissions.with_deleted.count %> + <% free_limit = BasilService::FREE_IMAGE_LIMIT %> + + <%= generated_count %> / <%= free_limit %> free images generated + + <% if generated_count >= free_limit %> + <%= link_to "Upgrade", subscription_path, class: "text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 underline" %> + <% end %> +
    + <% end %> +
    +
    + <% end %> +
    + + + + \ No newline at end of file diff --git a/app/views/content/edit/_navigation_sidebar.html.erb b/app/views/content/edit/_navigation_sidebar.html.erb new file mode 100644 index 000000000..66e969db3 --- /dev/null +++ b/app/views/content/edit/_navigation_sidebar.html.erb @@ -0,0 +1,129 @@ +
    + + + + +
    + + +
    +
    +

    Categories

    + +
    + + +
    + + +
    +

    Page Management

    + +
    + + + <%= render partial: 'content/shared/universe_card', locals: { raw_content: raw_content } %> + +
    +
    \ No newline at end of file diff --git a/app/views/content/edit/_secondary_sidebar.html.erb b/app/views/content/edit/_secondary_sidebar.html.erb new file mode 100644 index 000000000..cf9728c1c --- /dev/null +++ b/app/views/content/edit/_secondary_sidebar.html.erb @@ -0,0 +1,138 @@ + +
    +
    + + +
    +
    + <% if raw_content.class.name == 'Universe' %> + <%= link_to "/universes/#{raw_content.id}/contents", + target: "_blank", + class: "group flex items-center justify-between p-3 w-full rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 bg-gray-50 dark:bg-gray-800" do %> +
    + <%= Universe.icon rescue 'language' %> +
    +
    Encyclopedia
    +
    Explore this universe
    +
    +
    + open_in_new + <% end %> + <% end %> + <% if BasilService::ENABLED_PAGE_TYPES.include?(raw_content.class.name) %> + <%= link_to basil_content_path(raw_content.class.name, raw_content.id), + target: "_blank", + class: "group flex items-center justify-between p-3 w-full rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 bg-gray-50 dark:bg-gray-800" do %> +
    + visibility +
    +
    Visualize
    +
    Generate images
    +
    +
    + open_in_new + <% end %> + <% end %> + + <%= link_to attribute_customization_path(raw_content.class.name.downcase), + target: "_blank", + class: "group flex items-center justify-between p-3 w-full rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 bg-gray-50 dark:bg-gray-800" do %> +
    + tune +
    +
    Template Editor
    +
    Customize categories/fields
    +
    +
    + open_in_new + <% end %> + + <%= link_to polymorphic_path(raw_content, action: :changelog), + target: "_blank", + class: "group flex items-center justify-between p-3 w-full rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 bg-gray-50 dark:bg-gray-800" do %> +
    + history +
    +
    Changelog
    +
    View history
    +
    +
    + open_in_new + <% end %> + + <% if forum_url = ForumsLinkbuilderService.worldbuilding_url(raw_content.class) %> + <%= link_to forum_url, + target: "_blank", + class: "group flex items-center justify-between p-3 w-full rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 bg-gray-50 dark:bg-gray-800" do %> +
    + forum +
    +
    Community
    +
    Discuss page
    +
    +
    + open_in_new + <% end %> + <% end %> +
    +
    + +
    +
    +
    +
    + Saved +
    +
    + Auto-saving... +
    +
    +
    + + +
    +

    Page Stats

    +
    +
    + Word Count + <%= number_with_delimiter(raw_content.cached_word_count) %> +
    + + <% + total_fields = content.data[:categories].map { |cat| cat[:fields].count }.sum + completed_fields = content.data[:categories].map { |cat| cat[:fields].count { |field| field[:value].present? } }.sum + completion_percent = total_fields > 0 ? (completed_fields.to_f / total_fields * 100).round : 0 + %> +
    +
    + Completion + <%= completion_percent %>% +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    +
    diff --git a/app/views/content/form/_rich_text_input.html.erb b/app/views/content/form/_rich_text_input.html.erb index 435f881de..3bacac38e 100644 --- a/app/views/content/form/_rich_text_input.html.erb +++ b/app/views/content/form/_rich_text_input.html.erb @@ -1,5 +1,5 @@ <% - content_name = content.class.name + content_name = content.class_name field_id = "#{content_name}_#{field[:id]}" value ||= if content.respond_to?(field[:id].to_sym) @@ -22,18 +22,32 @@ placeholder = I18n.translate "attributes.#{content_name.downcase}.#{field[:label].downcase.gsub(/\s/, '_')}", scope: :serendipitous_questions, name: content.send('name') != "New #{content_name}" ? content.send('name') : "this #{content_name.downcase}", - default: 'Write as little or as much as you want' + default: 'Write as little or as much as you want!' %> -<%= hidden_field_tag "field[name]", field[:id] %> -<%= - text_area_tag "field[value]", - value, - class: "js-can-mention-pages materialize-textarea" \ - + "#{' autocomplete js-autocomplete-' + field[:id].to_s if should_autocomplete}" \ - + "#{' autosave-closest-form-on-change' if should_autosave}", - placeholder: placeholder -%> +
    + <%= hidden_field_tag "field[name]", field[:id] %> + <%= + text_area_tag "field[value]", + value, + class: "shadow-sm block w-full min-h-[3.5rem] focus:ring-notebook-blue focus:border-notebook-blue sm:text-sm border-0 border-t border-l-2 border-r-2 border-gray-200 dark:border-gray-700 rounded-md resize-y bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 placeholder:italic break-words" \ + + " js-can-mention-pages js-autosize-textarea" \ + + "#{' autocomplete js-autocomplete-' + field[:id].to_s if should_autocomplete}" \ + + "#{' js-autosave' if should_autosave}", + placeholder: placeholder, + rows: 3, + "data-enable-linking": "true" + %> + + <% if should_autosave %> + + <% end %> + + + <%= render partial: 'javascripts/content_linking_dropdown' %> +
    <%# todo switch to field[:options].fetch('privacy') %> <% if field[:label].start_with?('Private') %> @@ -46,12 +60,12 @@ <%= content_for :javascript do %> $(function() { $('.js-autocomplete-<%= field[:id].to_s %>').autocomplete({ - limit: 5, - data: { - <% autocomplete.each do |autocomplete_option| %> - "<%= autocomplete_option %>": null, + source: [ + <% autocomplete.each_with_index do |autocomplete_option, index| %> + "<%= autocomplete_option %>"<%= ',' unless index == autocomplete.length - 1 %> <% end %> - } + ], + minLength: 2 }); }); <% end %> diff --git a/app/views/content/form/_text_input.html.erb b/app/views/content/form/_text_input.html.erb index bfa4f2707..f8df91633 100644 --- a/app/views/content/form/_text_input.html.erb +++ b/app/views/content/form/_text_input.html.erb @@ -49,12 +49,12 @@ console.log("Initializing autocomplete for #<%= "#{content_name}_#{field.label}" %>"); $('.js-autocomplete-<%= field.id.to_s %>').autocomplete({ - limit: 5, - data: { - <% autocomplete.each do |autocomplete_option| %> - "<%= autocomplete_option %>": null, + source: [ + <% autocomplete.each_with_index do |autocomplete_option, index| %> + "<%= autocomplete_option %>"<%= ',' unless index == autocomplete.length - 1 %> <% end %> - } + ], + minLength: 2 }); }, 1000); }); diff --git a/app/views/content/form/_text_input_for_content_page.html.erb b/app/views/content/form/_text_input_for_content_page.html.erb index 00837ccbc..2a16bcde0 100644 --- a/app/views/content/form/_text_input_for_content_page.html.erb +++ b/app/views/content/form/_text_input_for_content_page.html.erb @@ -36,7 +36,7 @@ value, class: "js-can-mention-pages materialize-textarea" \ + "#{' autocomplete js-autocomplete-' + field[:id].to_s if should_autocomplete}" \ - + "#{' autosave-closest-form-on-change' if should_autosave}", + + "#{' js-autosave' if should_autosave}", placeholder: placeholder %>
    @@ -49,12 +49,12 @@ console.log("Initializing autocomplete for #<%= "#{content_name}_#{field.label}" %>"); $('.js-autocomplete-<%= field.id.to_s %>').autocomplete({ - limit: 5, - data: { - <% autocomplete.each do |autocomplete_option| %> - "<%= autocomplete_option %>": null, + source: [ + <% autocomplete.each_with_index do |autocomplete_option, index| %> + "<%= autocomplete_option %>"<%= ',' unless index == autocomplete.length - 1 %> <% end %> - } + ], + minLength: 2 }); }, 1000); }); diff --git a/app/views/content/form/field_types/_link.html.erb b/app/views/content/form/field_types/_link.html.erb index e69de29bb..75f1c6eb7 100644 --- a/app/views/content/form/field_types/_link.html.erb +++ b/app/views/content/form/field_types/_link.html.erb @@ -0,0 +1,164 @@ +<% + field_uuid = SecureRandom.uuid + + # Fall back on legacy link klass if we don't have any linkable types set on this link field + linkable_types = field.fetch(:migrated_link, false) \ + ? field.dig(:options, 'linkable_types') || [] # New-style link + : [raw_model.send(field[:old_column_source]).klass] # Old-style link + + # Constantize upfront so we can #icon, #color, etc + linkable_types.map! { |class_name| content_class_from_name(class_name) } +%> + +
    + + +
    +
    +
    + <%# Closed state %> +
    +
    +
    + <%# Chips for all selected options %> + + + <%# Placeholder component for when no options are selected %> +
    + +
    +
    + + <%# Dropdown arrow %> + +
    +
    + + <%# Opened state %> +
    +
    + + <%# Search box %> +
    + +
    + +
    + + + <%# No results message %> +
    + search_off +

    No pages found matching ""

    +
    + + <%# Empty state %> +
    +
    + link_off +

    + This field only accepts links to <%= linkable_types.to_sentence %> pages +

    +

    You haven't created any of those yet!

    +
    +
    +
    +
    +
    +
    +
    +
    + + <%# Help-text for which pages can be linked with this field %> + <%# TODO: maybe click one of these to open+filter the dropdown to just that type? %> +
    + + link + <% linkable_types.each do |page_type| %> + + <%= page_type.icon %> + + <% end %> + +
    +
    diff --git a/app/views/content/form/field_types/_migration_link.html.erb b/app/views/content/form/field_types/_migration_link.html.erb index 034e2d64c..e404b75b6 100644 --- a/app/views/content/form/field_types/_migration_link.html.erb +++ b/app/views/content/form/field_types/_migration_link.html.erb @@ -19,7 +19,7 @@
    - <% linkable_types.each do |page_type| %> <%# diff --git a/app/views/content/form/field_types/_tags.html.erb b/app/views/content/form/field_types/_tags.html.erb index 27b22d33f..28225892e 100644 --- a/app/views/content/form/field_types/_tags.html.erb +++ b/app/views/content/form/field_types/_tags.html.erb @@ -1,100 +1,150 @@ -
    -
    - <% if field[:label] %> - - <%= PageTag.icon %> - <%= f.label field[:id], field[:label] %> - - <% end %> -
    - <%= - hidden_field_tag 'field[value]', - page.page_tags.join(PageTag::SUBMISSION_DELIMITER), - class: 'hidden_page_tags_value' - %> +<% + field_uuid = SecureRandom.uuid +%> -
    - <%= PageTag.icon %> - Type and press enter to create a new tag, or click any of the suggested tags below to add it. +
    +
    +
    + <%= + hidden_field_tag 'field[value]', + page.page_tags.join(PageTag::SUBMISSION_DELIMITER), + class: 'hidden_page_tags_value js-autosave', + id: field_uuid + %> + +
    + +
    + +
    - <% if @suggested_page_tags %> -
    +
    +
    + + quick tags +
    +
    <% @suggested_page_tags.each do |tag| %> - <%= - link_to '#', class: 'js-add-tag' do - %> - - <% end %> + + <%= tag %> + <% end %> + +
    + Click any tag to add it to this page. Any tags you've added to your other <%= @content.class.name %> pages will also appear here. +
    - <% end %> - - <%= render partial: 'notice_dismissal/messages/24', locals: { page: page } %> +
    -<%= content_for :javascript do %> - function update_hidden_page_tag_value(e) { - var chips = M.Chips.getInstance($(e).parent().find('.chips')).chipsData.map(function (c) { - return c['tag']; - }); - var hidden_input = $(e).parent().find('.hidden_page_tags_value'); - hidden_input.val(chips.join('<%= PageTag::SUBMISSION_DELIMITER %>')); + diff --git a/app/views/content/form/field_types/_universe.html.erb b/app/views/content/form/field_types/_universe.html.erb index aaffa04d8..a61e17dab 100644 --- a/app/views/content/form/field_types/_universe.html.erb +++ b/app/views/content/form/field_types/_universe.html.erb @@ -1,60 +1,38 @@ -
    -
    -
    - <%= Universe.icon %> - <%= f.label field[:id], field[:label] %> -
    - - <% if page.new_record? || (page.persisted? && page.universe && page.universe.user == current_user) || page.universe_id.nil? || current_user.contributable_universes.count >= 1 # || page.universe_id.zero? %> - <%# todo not like this %> - <% - valid_universes = [] - show_premium_notice = false - - if Rails.application.config.content_types[:free].map(&:name).include?(raw_model.class.name) - valid_universes += current_user.universes - valid_universes += current_user.contributable_universes - else - # Premium content - if current_user.on_premium_plan? \ - || PermissionService.user_has_active_promotion_for_this_content_type(user: current_user, content_type: page.class.name) - - valid_universes += current_user.universes - - # Allow premium users to add premium content to non-premium universes - valid_universes += current_user.contributable_universes - else - show_premium_notice = true - end - - current_user.contributable_universes.each do |potential_universe| - if potential_universe.user.on_premium_plan? - valid_universes += [potential_universe] - end - end - end - %> - - <%= hidden_field_tag "field[name]", field[:id] %> - <%= - @universe_dropdown_options ||= valid_universes.uniq.sort_by(&:name).map { |u| [u.name, u.id] } - select_tag "field[value]", - options_for_select( - @universe_dropdown_options.compact, - page.try(:universe_id) || @universe_scope.try(:id) - ), - include_blank: current_user.on_premium_plan? || Rails.application.config.content_types[:free].map(&:name).include?(raw_model.class.name), - class: 'autosave-closest-form-on-change' - %> - <% if show_premium_notice %> -
    - info - While on a <%= link_to 'Starter plan', subscription_path %>, you can only create premium content in universes you're a contributor to. -
    - <% end %> - <% else %> -
    - <%= link_to(page.universe.name, page.universe) if page.universe %> - <% end %> +<% if @linkables_raw.fetch('Universe', []).any? %> +
    + <%= hidden_field_tag "field[name]", field[:id] %> + <%= + # TODO: audit which universes are included here for 1. collaborating and 2. free users collaborating on premium universes + select_tag "field[value]", + options_for_select( + @linkables_raw['Universe'].compact.sort_by(&:name).map { |u| + [u.name, u.id, { 'data-universe-privacy': u.privacy || 'private' }] + }, + raw_model.try(:universe_id) || @universe_scope.try(:id) + ), + include_blank: true, + class: 'js-autosave universe-select-field block w-full px-3 py-2 text-base border-gray-300 dark:border-gray-700 focus:outline-none focus:ring-purple-800 focus:border-purple-800 sm:text-sm rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100', + data: { + 'universe-select': true, + 'content-id': raw_model.id, + 'content-type': raw_model.class.name + } + %>
    -
    \ No newline at end of file +<% else %> + <%= link_to new_universe_path do %> +
    + <%= Universe.icon %> + +
    +

    + You haven't created any universes yet! +

    +

    + Universes allow you to organize your separate worlds and focus on just one universe's pages at a time. Your changes here are automatically saved. Click this box to go + create your first universe. +

    +
    +
    + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/content/form/gallery/_panel.html.erb b/app/views/content/form/gallery/_panel.html.erb index dcb342fca..eac1921e6 100644 --- a/app/views/content/form/gallery/_panel.html.erb +++ b/app/views/content/form/gallery/_panel.html.erb @@ -34,7 +34,7 @@ image_data = image_item[:data] image_type = image_item[:type] image_id = image_item[:id] - is_pinned = image_data.respond_to?(:pinned?) && image_data.pinned? + is_pinned = image_data.pinned == true %>