diff --git a/.gitignore b/.gitignore index 9ed66fe2d..0da4d3f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ node_modules yarn-debug.log* .yarn-integrity +# Heroku stuff +.env diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 000000000..36d725e36 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "16.20.2" diff --git a/Gemfile b/Gemfile index 0a4f74df9..7e6566d01 100644 --- a/Gemfile +++ b/Gemfile @@ -55,7 +55,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 +106,9 @@ gem 'redis', '~> 5.1.0' # Exports gem 'csv' +# Diff generation for document revisions +gem 'diffy' + # Admin gem 'rails_admin' @@ -132,8 +135,6 @@ 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 @@ -148,7 +149,7 @@ end group :development do gem 'web-console' - gem 'listen' + # gem 'listen' gem 'bullet' gem 'rack-mini-profiler' gem 'memory_profiler' @@ -163,7 +164,8 @@ group :worker do # Document understanding gem 'htmlentities' gem 'birch', git: 'https://github.com/billthompson/birch.git', branch: 'birch-ruby22' - gem 'engtagger', github: 'yohasebe/engtagger', ref: 'master' # we might want this in more groups...? + gem 'engtagger', '~> 0.4.2' # we might want this in more groups...? gem 'ibm_watson' gem 'textstat' end + diff --git a/Gemfile.lock b/Gemfile.lock index d9ba876ff..86cc2d5b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -51,14 +51,6 @@ GIT sprockets-es6 timeago_js (>= 3.0.2.2) -GIT - remote: https://github.com/yohasebe/engtagger.git - revision: c857bf2111e65517e6970cdb04c8bfa32c5cb1db - ref: master - specs: - engtagger (0.4.2) - sin_lru_redux - GEM remote: https://rubygems.org/ specs: @@ -1835,6 +1827,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) @@ -1849,6 +1842,8 @@ GEM dotenv-rails (3.1.8) dotenv (= 3.1.8) railties (>= 6.1) + engtagger (0.4.2) + sin_lru_redux erubi (1.13.1) event_emitter (0.2.6) eventmachine (1.2.7) @@ -1965,11 +1960,6 @@ GEM letter_opener (~> 1.9) railties (>= 6.1) rexml - 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) @@ -1997,8 +1987,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 @@ -2113,9 +2101,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 @@ -2236,7 +2221,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) @@ -2302,9 +2286,10 @@ DEPENDENCIES database_cleaner dateslices devise + diffy discordrb dotenv-rails - engtagger! + engtagger (~> 0.4.2) factory_bot_rails filesize flamegraph @@ -2315,11 +2300,9 @@ DEPENDENCIES image_processing language_filter letter_opener_web - listen material_icons memory_profiler meta-tags - mini_racer (~> 0.6.3) minitest-reporters (~> 1.1) onebox! paperclip @@ -2357,7 +2340,6 @@ DEPENDENCIES terser textstat thredded! - tribute uglifier (>= 1.3.0) web-console webmock (~> 3.0) 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/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..95b2d61f4 --- /dev/null +++ b/app/assets/javascripts/alpine.js @@ -0,0 +1,131 @@ +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; + } + + // Update the original select element's selected options + const originalSelect = document.getElementById(this.sourceFieldId); + for (let i = 0; i < originalSelect.options.length; i++) { + originalSelect.options[i].selected = this.options[i].selected; + } + + // Finally, trigger a manual on-change event on the original select + // to make sure our autosave fires on it. + originalSelect.dispatchEvent(new Event('change')); + }, + remove(index, option) { + this.options[option].selected = false; + this.selected.splice(index, 1); + + // We also want to manually remove the `selected` attribute from the + // original select element's option. + const originalSelect = document.getElementById(this.sourceFieldId); + originalSelect.options[option].selected = false; + + // After removing the option, we want to emit a change event to trigger + // a field autosave. + originalSelect.dispatchEvent(new Event('change')); + }, + loadOptions(fieldId) { + this.sourceFieldId = fieldId; + const select = document.getElementById(fieldId); + const optgroups = select.getElementsByTagName('optgroup'); + + // Since we're effectively resetting indices per optgroup, we need to track + // a single running index throughout all optgroups to use as an index for + // each option for events, etc. + let runningOptionIndex = 0; + + // For each optgroup (page type)... + for (let i = 0; i < optgroups.length; i++) { + const groupOptions = optgroups[i].getElementsByTagName('option'); + + // Prepare the `options` array for this optgroup + const optionsForThisOptGroup = []; + for (let j = 0; j < groupOptions.length; j++) { + const option = groupOptions[j]; + const imageUrl = option.getAttribute('data-image-url'); + + optionsForThisOptGroup.push({ + index: runningOptionIndex++, + value: option.value, + text: option.textContent.trim(), + imageUrl: imageUrl, + icon: option.getAttribute('data-icon'), + icon_color: option.getAttribute('data-icon-color'), + selected: !!option.selected + }); + + if (!!option.selected) { + this.selected.push(runningOptionIndex - 1); + } + } + + // Finally, add it as a valid optgroup with options + if (optionsForThisOptGroup.length > 0) { + const optgroupData = { + label: optgroups[i].label, + icon: window.ContentTypeData[optgroups[i].label].icon, + color: window.ContentTypeData[optgroups[i].label].color, + textColor: window.ContentTypeData[optgroups[i].label].text_color || 'text-gray-600', + iconColor: window.ContentTypeData[optgroups[i].label].text_color || 'text-gray-600', + plural: window.ContentTypeData[optgroups[i].label].plural, + options: optionsForThisOptGroup, + filteredOptions: optionsForThisOptGroup // Initialize with all options + }; + this.optgroups.push(optgroupData); + + // And also track all the options in a flat array so we can reference them with a single index + for (let j = 0; j < optionsForThisOptGroup.length; j++) { + this.options.push(optionsForThisOptGroup[j]); + } + } + } + }, + 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..3cc3e3902 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -15,8 +15,6 @@ //= require Chart.bundle //= require chartkick //= require autocomplete-rails -//= require tribute +// require tribute - Replaced with Alpine.js implementation //= require d3 //= 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 index 24a86364d..8f8f63ab7 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,26 +1,61 @@ $(document).ready(function() { + var recent_autosave = false; + $('.autosave-closest-form-on-change').change(function () { var content_form = $(this).closest('form'); + var default_border_class = 'border-gray-200'; // This needs to match whatever the actual CSS on the element is! + var in_progress_saving_class = 'border-yellow-400'; + var saved_successfully_class = 'border-green-400'; + var error_saving_class = 'border-red-400'; + + // Submit content_form with ajax if (content_form) { - M.toast({ html: 'Saving your changes...' }); + recent_autosave = true; + setTimeout(() => recent_autosave = false, 1000); var form_data = content_form.serialize(); - form_data += "&authenticity_token=" + $('meta[name="csrf-token"]').attr('content'); + form_data += "&authenticity_token=" + encodeURIComponent($('meta[name="csrf-token"]').attr('content')); + var field = $(this); + + console.log('wip saving'); + field.removeClass(default_border_class); + field.addClass(in_progress_saving_class); $.ajax({ url: content_form.attr('action') + '.json', type: content_form.attr('method').toUpperCase(), data: form_data, success: function(response) { - M.toast({ html: 'Saved successfully!' }); + console.log('saved ok'); + field.removeClass(in_progress_saving_class); + field.addClass(saved_successfully_class); + + // Dispatch a custom event for successful autosave + var event = new CustomEvent('autosave:success', { + detail: { field: field[0], response: response } + }); + document.dispatchEvent(event); + + // Reset back to default coloring after 10 seconds + setTimeout(function () { + field.removeClass(saved_successfully_class); + field.addClass(default_border_class); + }, 10000); }, error: function(response) { - M.toast({ html: "There was an error saving your changes. Please back up any changes and refresh the page." }); + console.log('error saving'); + field.removeClass(in_progress_saving_class); + field.addClass(error_saving_class); + + // TODO show some modal or something } }); } else { - M.toast({ html: "There was an error saving your changes. Please back up any changes and refresh the page." }); + console.log('error saving changes'); + + // TODO show some message to refresh the page or something + console.error("There was an error saving your changes. Please back up any changes and refresh the page."); } }); @@ -31,6 +66,8 @@ $(document).ready(function() { // To ensure all fields get unblurred (and therefore autosaved) upon navigation, // we use this little ditty: window.onbeforeunload = function(e){ - $(document.activeElement).blur(); + if (!recent_autosave) { + $(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..cc3671a33 --- /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 = 2; // Minimum number of lines to display + const minHeight = yPadding + (minLines * lineHeight); // Minimum height for 2 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 index 8e4e2a2bc..1c759a8b4 100644 --- a/app/assets/javascripts/document_editor.js +++ b/app/assets/javascripts/document_editor.js @@ -1,3 +1,6 @@ +// Ensure Notebook namespace exists +if (!window.Notebook) { window.Notebook = {}; } + Notebook.DocumentEditor = class DocumentEditor { constructor(el) { this.el = el; diff --git a/app/assets/javascripts/enhanced_autosave.js b/app/assets/javascripts/enhanced_autosave.js new file mode 100644 index 000000000..50b025212 --- /dev/null +++ b/app/assets/javascripts/enhanced_autosave.js @@ -0,0 +1,218 @@ +$(document).ready(function() { + var recent_autosave = false; + var autosave_timers = {}; + var input_debounce_timers = {}; + + function showToast(message, type = 'success') { + // Remove any existing toasts + $('.js-autosave-toast').remove(); + + var bgClass = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-yellow-500'; + var toast = $(` +
+ ${message} +
+ `); + + $('body').append(toast); + + // Animate in + setTimeout(() => { + toast.removeClass('translate-x-full opacity-0'); + }, 100); + + // Animate out after delay + setTimeout(() => { + toast.addClass('translate-x-full opacity-0'); + setTimeout(() => toast.remove(), 300); + }, type === 'error' ? 4000 : 2000); + } + + function updateFieldVisualState(field, state) { + // Remove all autosave-related classes + field.removeClass('border-gray-300 border-yellow-400 border-green-400 border-red-400 border-2 border-4'); + + // Find the status element in the same container + var statusElement = field.closest('.mt-4').find('.js-autosave-status'); + var statusText = statusElement.find('.js-status-text'); + + switch(state) { + case 'saving': + field.addClass('border-yellow-400 border-2'); + 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 border-2'); + 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 border-2'); + 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 + updateFieldVisualState(field, 'saving'); + saveIndicator.addClass('hidden'); + showToast('Saving...', 'saving'); + + $.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'); + + // Show "Saved!" indicator + saveIndicator.removeClass('hidden'); + 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'); + 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'); + 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]); + } + + // Show that changes are pending + updateFieldVisualState(field, 'saving'); + + // Set new timer for 10 seconds + autosave_timers[fieldId] = setTimeout(function() { + if (field.is(':focus') && field.val().trim().length > 0) { + performAutosave(field); + } else { + // Reset visual state if we're not going to save + updateFieldVisualState(field, 'default'); + } + 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); + } + + // Enhanced autosave for serendipitous questions + $('.js-enhanced-autosave').on('input', function() { + var field = $(this); + + // Debounce input to avoid setting up too many timers during rapid typing + debounceInput(field, function() { + setupAutosaveTimer(field); + }, 300); // 300ms debounce + }); + + $('.js-enhanced-autosave').on('blur', function() { + var field = $(this); + var fieldId = field.attr('id') || field.attr('name') || Math.random().toString(36); + + // Clear any pending timers since we're saving on 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 there's content + if (field.val().trim().length > 0) { + performAutosave(field); + } else { + // Reset visual state if no content to save + updateFieldVisualState(field, 'default'); + } + }); + + // Focus event to reset any error states + $('.js-enhanced-autosave').on('focus', function() { + var field = $(this); + var saveIndicator = field.siblings('.js-save-indicator'); + + // Hide any existing save indicators when user starts editing again + if (!saveIndicator.hasClass('hidden')) { + setTimeout(function() { + saveIndicator.addClass('hidden'); + }, 500); + } + }); + + // 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/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/tailwind_initialization.js b/app/assets/javascripts/tailwind_initialization.js new file mode 100644 index 000000000..23f3b8704 --- /dev/null +++ b/app/assets/javascripts/tailwind_initialization.js @@ -0,0 +1,80 @@ +//# 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'); + } + }); + }); + + // Initialize tooltips + document.querySelectorAll('[data-tooltip]').forEach(element => { + const tooltipText = element.getAttribute('data-tooltip'); + + element.addEventListener('mouseenter', function(e) { + const tooltip = document.createElement('div'); + tooltip.className = 'absolute z-10 px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-sm tooltip'; + tooltip.textContent = tooltipText; + tooltip.style.top = `${e.target.offsetTop - 40}px`; + tooltip.style.left = `${e.target.offsetLeft + (e.target.offsetWidth / 2) - 80}px`; + + document.body.appendChild(tooltip); + + // Position tooltip + const rect = tooltip.getBoundingClientRect(); + if (rect.left < 0) { + tooltip.style.left = '0px'; + } else if (rect.right > window.innerWidth) { + tooltip.style.left = `${window.innerWidth - rect.width - 10}px`; + } + + // Add arrow + const arrow = document.createElement('div'); + arrow.className = 'tooltip-arrow'; + arrow.style.position = 'absolute'; + arrow.style.width = '10px'; + arrow.style.height = '10px'; + arrow.style.background = '#1F2937'; + arrow.style.transform = 'rotate(45deg)'; + arrow.style.bottom = '-5px'; + arrow.style.left = 'calc(50% - 5px)'; + tooltip.appendChild(arrow); + }); + + element.addEventListener('mouseleave', function() { + const tooltip = document.querySelector('.tooltip'); + if (tooltip) { + document.body.removeChild(tooltip); + } + }); + }); +}; + +// 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..b8eb7e841 100644 --- a/app/assets/javascripts/timeline-editor.js +++ b/app/assets/javascripts/timeline-editor.js @@ -1,152 +1,1006 @@ -$(document).ready(function () { - function get_event_id_from_url(url) { - return url.split('/')[4]; +// 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; } - $('.js-trigger-autosave-on-change').change(function () { - $(this).closest('.autosave-form').submit(); - M.toast({ - html: "Autosaving..." - }); + 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)); + }, + 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'); + } }); - $('.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; + } - return false; + .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; + + // 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'); + + // 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); + } + } + }); - return false; + 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(); + } }); - $('.js-move-event-down').click(function () { - var event_container = $(this).closest('.timeline-event-container'); - var event_id = event_container.data('event-id'); + document.addEventListener('drop', function(e) { + const dropZone = e.target.closest('.event-drop-zone'); + if (dropZone) { + e.preventDefault(); - $.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 + '"]'); + try { + const dragData = JSON.parse(e.dataTransfer.getData('application/json')); + const eventId = dropZone.dataset.eventId; - event_container.insertAfter(event_container.next()); - }).fail(function() { - alert("Something went wrong and your change didn't save. Please try again."); - }); + 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); + + // Auto-remove after 4 seconds + setTimeout(() => { + toast.addClass('translate-x-full'); + setTimeout(() => toast.remove(), 300); + }, 4000); +} + +document.addEventListener('DOMContentLoaded', function() { + + // 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'; + } + } + }); - return false; + // Create timeline event + document.getElementById('js-create-timeline-event').addEventListener('click', function() { + const timelineId = document.querySelector('.timeline-events-container').dataset.timelineId; + createTimelineEvent(timelineId); }); - $('.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 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 && alpineEl._x_dataStack ? alpineEl._x_dataStack[0].linkingEventId : null; + + if (eventId) { + // Show loading state on the clicked button + const originalContent = button.innerHTML; + button.innerHTML = '
Linking...
'; + button.disabled = true; + + 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; + }); + } + } + }); + + + // 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!'); + } + } + }); + + 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} + +
+ `; + + document.body.appendChild(toast); - return false; + // Animate in + setTimeout(() => { + toast.style.transform = 'translateX(0)'; + }, 10); + + // Auto remove after 3 seconds + setTimeout(() => { + if (toast.parentElement) { + toast.style.transform = 'translateX(full)'; + setTimeout(() => toast.remove(), 300); + } + }, 3000); + } + + // Move event handlers + document.addEventListener('click', function(event) { + const eventContainer = event.target.closest('.timeline-event-container'); + const eventId = eventContainer?.dataset.eventId; + + if (!eventId || eventId === '-1') return; + + 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`; + } + + 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.'); + }); + } }); - $('#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') + 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); + } + } + + // Global function for unlinking from sidebar + window.unlinkFromSidebar = function(unlinkHref, button) { + + // 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); + + // Update sidebar + const alpineEl = document.querySelector('[x-data]'); + if (alpineEl && Alpine.$data(alpineEl).selectedEventId) { + Alpine.$data(alpineEl).updateSidebarLinkedContent(Alpine.$data(alpineEl).selectedEventId); } + + showSuccessMessage(data.message || 'Content unlinked successfully!'); + } else { + throw new Error(data.message || 'Failed to unlink content'); } - ).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); + }) + .catch(error => { + button.innerHTML = originalHTML; + button.disabled = false; + showErrorMessage('Error unlinking content. Please try again.'); + }); + }; + + // Make deleteEvent globally available + window.deleteEvent = function(eventId, button) { + const eventContainer = button.closest('.timeline-event-container'); - // Update IDs to the newly-created event - cloned_template.data('event-id', new_event_id); - cloned_template.attr('data-event-id', new_event_id); + // If this is a template event (not yet saved), just remove it + if (!eventId || eventId === 'null') { + eventContainer.remove(); + return; + } - // 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); + // Show confirmation modal + if (!confirm('Are you sure you want to delete this event? This cannot be undone.')) { + return; + } - 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); + // Show loading state + const originalIcon = button.innerHTML; + button.innerHTML = '
'; + button.disabled = true; - 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); + 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(); + } - //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); + // Animate removal + eventContainer.style.transition = 'all 0.3s ease-in'; + eventContainer.style.opacity = '0'; + eventContainer.style.transform = 'translateX(-20px)'; - cloned_template.appendTo(events_container); + setTimeout(() => { + eventContainer.remove(); - loading_indicator.hide(); - $('#js-create-timeline-event').removeAttr('disabled'); + // Update event count + const eventsContainer = document.querySelector('.timeline-events-container'); + const eventCount = eventsContainer.querySelectorAll('.timeline-event-container:not(.timeline-event-template)').length; + updateEventCount(eventCount); - }).fail(function () { - alert('Error 292'); + // Show empty state if no events remain + if (eventCount === 0) { + showEmptyState(); + } - loading_indicator.hide(); - $('#js-create-timeline-event').removeAttr('disabled'); + showSuccessMessage('Event deleted successfully!'); + }, 300); + }) + .catch(error => { + button.innerHTML = originalIcon; + button.disabled = false; + showErrorMessage('Error deleting event. Please try again.'); }); + }; - // return false so we don't jump to the top of the page - return false; - }); + 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/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/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..d0725bc31 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; @@ -221,6 +244,7 @@ body { nav.thredded--pagination { background: #3B4043; + a { color: lightgrey; @@ -232,7 +256,7 @@ body { .thredded--topics--topic { background-color: #3B4043; - + .thredded--topics--title a { color: white; } @@ -282,4 +306,4 @@ body { color: #fff !important; } } -} +} \ No newline at end of file diff --git a/app/assets/stylesheets/editor.scss b/app/assets/stylesheets/editor.scss index 184848637..ce9ff1973 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; 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..b1ce2cb88 --- /dev/null +++ b/app/assets/stylesheets/moderation.scss @@ -0,0 +1,1873 @@ +// 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 +.users-header { + background: #1f2937; + border-color: #374151; + + .users-title { + h2 { + color: #f3f4f6; + } + } + + .users-search { + .search-input-wrapper { + background: #111827; + border-color: #374151; + + &:focus-within { + border-color: #42a5f5; + background: #111827; + } + + .search-input { + color: #f3f4f6; + + &::placeholder { + color: #9ca3af; + } + } + + .search-button { + background: #2196F3; + + &:hover { + background: #1976D2; + } + } + } + } +} + +.search-results-notice { + background: #1e3a8a; + border-color: #1d4ed8; + color: #bfdbfe; +} + +.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; + } + } + + 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; + } + } + } + } + } + } + } +} + +.no-results { + .no-results-icon { + color: #374151; + } + + h3 { + color: #f3f4f6; + } + + p { + color: #9ca3af; + } + + .clear-search-btn { + color: #42a5f5; + + &:hover { + color: #60a5fa; + } + } +} + +// Users Management Page +.users-header { + background: #1f2937; + border-color: #374151; + + .users-title { + h2 { + color: #f3f4f6; + } + } + + .users-search { + .search-input-wrapper { + background: #111827; + border-color: #374151; + + &:focus-within { + border-color: #42a5f5; + background: #111827; + } + + .search-input { + color: #f3f4f6; + + &::placeholder { + color: #9ca3af; + } + } + + .search-button { + background: #2196F3; + + &:hover { + background: #1976D2; + } + } + } + } +} + +.search-results-notice { + background: #1e3a8a; + border-color: #1d4ed8; + color: #bfdbfe; +} + +.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; + } + } + + 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; + } + } + } + } + } + } + } +} + +.no-results { + .no-results-icon { + color: #374151; + } + + h3 { + color: #f3f4f6; + } + + p { + color: #9ca3af; + } + + .clear-search-btn { + color: #42a5f5; + + &:hover { + color: #60a5fa; + } + } +} + +// Users Management Page +.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..ffc5593d2 --- /dev/null +++ b/app/assets/stylesheets/private-messages.scss @@ -0,0 +1,524 @@ +// 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; + } + } +} + +// 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..5b9f824a8 --- /dev/null +++ b/app/assets/stylesheets/tailwind-custom.scss @@ -0,0 +1,173 @@ +.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-bg { + background-color: white; + border-right: 1px solid #e5e7eb; + /* gray-200 */ +} + +.dark .sidebar-bg { + background-color: #404043; + border-right: 1px solid #202023; +} + +.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 */ +} \ No newline at end of file diff --git a/app/assets/stylesheets/tailwind.css b/app/assets/stylesheets/tailwind.css new file mode 100644 index 000000000..69acb647a --- /dev/null +++ b/app/assets/stylesheets/tailwind.css @@ -0,0 +1,4 @@ +/* Tailwind CSS imports */ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/app/assets/stylesheets/thredded-overrides.scss b/app/assets/stylesheets/thredded-overrides.scss index 928cf365d..461399ed8 100644 --- a/app/assets/stylesheets/thredded-overrides.scss +++ b/app/assets/stylesheets/thredded-overrides.scss @@ -1,232 +1,194 @@ -@import "thredded"; - -#thredded--container { - .thredded--navigation--search { - - #q /* search input */ { - padding-left: 16px; - margin-top: 4.5em; - } +// 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; +} - @media only screen and (min-width: 600px) { - #q /* search input */ { - height: 17px; - margin-top: 0; - } - } - } +// 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; +} - .thredded--navigation-breadcrumbs { - margin-top: 0.4em; - padding-left: 0.1em; - overflow: hidden; - min-height: 3em; - max-height: 60px; +// Specific IDs for thredded icons (belt and suspenders approach) +#thredded-follow-icon, +#thredded-lock-icon, +#thredded-unfollow-icon { + display: none !important; +} - width: 50%; +// Dark Mode Overrides for Forum Topics +.dark { + .thredded-topic-header { + background-color: #111827 !important; // gray-900 + border-color: #1f2937 !important; // gray-800 - li { - display: inline !important; - float: left; - padding-right: 4px; - margin-right: 8px; + h1 { + color: #ffffff !important; } - li a { - padding: 0; - line-height: 2rem; + .text-stone-500 { + color: rgba(255, 255, 255, 0.6) !important; } - } - .thredded--scoped-navigation { - left: 1rem; - top: 1rem; - } + .text-stone-600 { + color: #d1d5db !important; // gray-300 + } - .thredded--scoped-navigation li a { - padding: 0; - line-height: 2rem; - float: left; - } + .text-stone-900 { + color: #ffffff !important; + } - .thredded--user-navigation { - height: 2rem; - margin: 1rem; - border-bottom: 0; + .bg-stone-50 { + background-color: rgba(255, 255, 255, 0.05) !important; + border-color: rgba(255, 255, 255, 0.1) !important; - @media only screen and (max-width: 600px) { - .thredded--user-navigation--item { - padding-top: 0.5rem; - padding-right: 0.5rem; + &:hover { + background-color: rgba(255, 255, 255, 0.1) !important; } } - .thredded--user-navigation--item a { - padding: 8px 4px; - 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; - @media only screen and (max-width: 600px) { - .thredded--user-navigation { - margin: 0; + &:hover { + background-color: rgba(255, 255, 255, 0.1) !important; + } } } - .thredded--post--user { - color: black; - - a { - color: #347a36; + .thredded-post-card { + .bg-white { + background-color: #111827 !important; // gray-900 + border-color: #1f2937 !important; // gray-800 } - } - .thredded--currently-online { - right: 100px; - max-height: 80%; - overflow-y: hidden; - overflow-x: hidden; - border: 1px solid lightgrey; - } - - .thredded--currently-online.thredded--is-expanded { - overflow-y: auto; - overflow-x: hidden; - } - - .thredded--new-topic-form { - background: white; - padding: 10px; - padding-bottom: 0; - border: 1px solid lightgrey; + .border-stone-200 { + border-color: #1f2937 !important; // gray-800 + } - margin-bottom: 20px; - } + .border-stone-300 { + border-color: #374151 !important; // gray-700 + } - .thredded--topics--topic { - background: white; - padding: 4px 26px; - border: 1px solid lightgrey; + .border-stone-100 { + border-color: #1f2937 !important; // gray-800 + } - margin-bottom: 0.7rem; - } + .bg-white\/50 { + background-color: rgba(255, 255, 255, 0.05) !important; + } - .thredded--topics--posts-count { - left: -1rem; - top: 2px; - } + .text-stone-900 { + color: #f3f4f6 !important; // gray-100 + } - .thredded--topics--follow-icon { - right: 0.2rem; - top: 6px; - } + .text-stone-500 { + color: #9ca3af !important; // gray-400 + } - .thredded--pagination { - background: white; - padding: 0 0 10px 0; - } + .text-stone-800 { + color: #e5e7eb !important; // gray-200 + } - .thredded--topic .thredded--post { - background: white; - padding: 10px; + .bg-stone-200 { + background-color: #374151 !important; // gray-700 + } - margin-bottom: 20px; - border-bottom: 1px solid lightgrey; - } + .bg-stone-300 { + background-color: #4b5563 !important; // gray-600 + } - @media (min-width: 47.12501rem) { - .thredded--post--avatar { - top: 0; + .bg-stone-50 { + background-color: rgba(0, 0, 0, 0.2) !important; + border-color: #1f2937 !important; // gray-800 } - } - .thredded--messageboard { - background: white; - border-bottom: 1px solid lightgrey; - margin: 2px; - } -} + .prose-stone { + color: #e5e7eb !important; // gray-200 -.thredded--main-header { - nav { - background: white; + strong { + color: #fff !important; + } - margin-bottom: 4rem; - line-height: 3rem; - } + a { + color: #60a5fa !important; + } + + blockquote { + color: #d1d5db !important; + border-left-color: #374151 !important; + } + code { + color: #f3f4f6 !important; + } - @media only screen and (min-width: 600px) { - nav { - height: 3rem; + h1, + h2, + h3, + h4 { + color: #f3f4f6 !important; + } } } -} -.thredded--topic-header { - .thredded--topic-header--title { - margin-bottom: 0.4em; - } -} + // Forums Index Header + .thredded-forums-header { + background-color: #111827 !important; // gray-900 + border-color: #1f2937 !important; // gray-800 -.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; - } -} + h1 { + color: #ffffff !important; + } -.thredded--form { - input[type="checkbox"] { - position: relative !important; - opacity: 1 !important; - pointer-events: inherit !important; + .text-stone-500 { + color: rgba(255, 255, 255, 0.6) !important; + } } -} -.thredded--messageboard--byline { - i.material-icons { - font-size: 0.9em; - color: grey; - } -} + // 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--post--content { - padding-top: 0.6em !important; + &:hover { + background-color: #111827 !important; // gray-900 + border-color: #374151 !important; // gray-700 + } - ul { - list-style-type: inherit !important; - padding-left: 40px !important; + h3 { + color: #f3f4f6 !important; // gray-100 + } - li { - list-style-type: disc !important; + .text-gray-900 { + color: #f3f4f6 !important; } - } - ol { - list-style-type: inherit !important; - padding-left: 40px !important; - li { - list-style-type: decimal !important; + .text-gray-500, + .text-gray-600, + .text-gray-700 { + color: #9ca3af !important; // gray-400 } - } -} -.muted-thredded-post { - color: #616161; - background-color: #f5f5f5 !important; - border-bottom: 0 !important; -} + .messageboard-stats { + color: #9ca3af !important; + } -.thredded--post { - .card-content { - padding: 0 !important; - padding-top: 5px !important; + .messageboard-footer { + border-top-color: #374151 !important; // gray-700 + background-color: rgba(0, 0, 0, 0.2) !important; // Darken footer bg + } } -} -.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_events.css b/app/assets/stylesheets/timeline_events.css new file mode 100644 index 000000000..f35627ff0 --- /dev/null +++ b/app/assets/stylesheets/timeline_events.css @@ -0,0 +1,719 @@ +/* ========================= + 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; /* Centered with dots: 16px (dot left) + 24px (w-12/2) - 1px (spine w-0.5/2) = 39px */ +} + +/* 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 { + background: white; + border: 1px solid #e5e7eb; + 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; +} + +/* Selected Event State */ +.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 { + background: linear-gradient(135deg, #fefefe 0%, #f8fafc 100%); + border-bottom: 1px solid #f1f5f9; + border-radius: 16px 16px 0 0; + position: relative; +} + +.timeline-event-card .px-6.py-4.border-b::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #e2e8f0 50%, + transparent 100% + ); +} + +/* Event Title Input Styling */ +.timeline-event-card input[name*="[title]"] { + font-weight: 600; + color: #1f2937; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 0.25rem 0; + transition: all 0.2s ease; +} + +.timeline-event-card input[name*="[title]"]:hover { + border-bottom-color: #e5e7eb; +} + +.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]"] { + color: #6b7280; + 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]"]:hover { + border-bottom-color: #d1d5db; +} + +.timeline-event-card input[name*="[time_label]"]:focus { + border-bottom-color: #10b981; + color: #374151; +} + +/* Timeline Header Styling */ +.timeline-header-container { + position: relative; + margin-bottom: 2rem; +} + +/* Inline Edit Styling */ +.timeline-inline-edit-input { + background: white; + border: 1px solid #d1d5db; + 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 { + background: white; + border: 1px solid #e5e7eb; + 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 { + background: linear-gradient(135deg, #fefefe 0%, #f8fafc 100%); + border-bottom: 1px solid #f1f5f9; + position: relative; +} + +.timeline-header-toggle::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + #e2e8f0 50%, + transparent 100% + ); +} + +.timeline-header-toggle:hover { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); +} + +.timeline-header-description { + background: #fefefe; + border-top: 1px solid #f3f4f6; +} + +.timeline-header-description textarea { + background: #ffffff; + border: 1px solid #e5e7eb; + transition: all 0.2s ease; + font-family: inherit; + line-height: 1.6; +} + +.timeline-header-description textarea:hover { + border-color: #d1d5db; +} + +.timeline-header-description textarea:focus { + border-color: #3b82f6; + 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]"] { + background: #fefefe; + border: 1px solid #f3f4f6; + 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]"]:hover { + background: #ffffff; + border-color: #e5e7eb; +} + +.timeline-event-card textarea[name*="[description]"]:focus { + background: #ffffff; + border-color: #10b981; + 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; +} + +/* Action Dropdown Menu */ +.timeline-move-controls .absolute.right-0 { + background: white; + border: 1px solid #e5e7eb; + 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; + border-bottom: 1px solid #f9fafb; +} + +.timeline-move-controls .absolute.right-0 a:not(.text-red-600) { + color: #374151; +} + +.timeline-move-controls .absolute.right-0 a:not(.text-red-600):hover { + background: #f8fafc; + color: #10b981; +} + +.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-specific empty state styling */ +.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; +} + +/* Subtle Background Pattern */ +.timeline-event-card .px-6.py-4.space-y-4 { + background-image: + radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.03) 1px, transparent 0); + background-size: 20px 20px; + background-position: 0 0; +} + +/* Paper Texture Effect */ +.timeline-event-card { + background-image: + linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.8) 49%, rgba(255,255,255,0.8) 51%, transparent 52%), + linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.6) 49%, rgba(255,255,255,0.6) 51%, transparent 52%); + background-size: 3px 3px; +} + +/* 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; +} + +/* Mobile drag handle adjustments */ +@media (max-width: 768px) { + .timeline-event-drag-handle { + padding: 0.75rem !important; + margin-right: 0.5rem !important; + } + + .timeline-event-placeholder { + height: 100px !important; + border-radius: 12px; + } + + /* Mobile duration field adjustments */ + .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; + } + + /* Mobile timeline header adjustments */ + .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; + } +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .timeline-event-card { + margin-left: 0; + border-radius: 12px; + } + + /* Keep timeline spine visible on medium screens */ + + .timeline-dot { + position: absolute; + /* Keep left/top positioning */ + } + + /* Tablet duration field adjustments */ + .timeline-duration-fields { + gap: 0.5rem; + } + + .timeline-duration-icon { + font-size: 0.9rem !important; + } + + /* Tablet timeline header adjustments */ + .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; + } +} + +/* Tag Autocomplete Styling */ +.tag-autocomplete-dropdown { + backdrop-filter: blur(8px); + border: 1px solid rgba(229, 231, 235, 0.8); + background: rgba(255, 255, 255, 0.95); +} + +.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; +} + +/* Animation for tag selection */ +.tag-selected-animation { + animation: tagSelect 0.3s ease-out; +} + +@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; + } +} + +/* Enhanced tag input focus state */ +.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; +} + diff --git a/app/assets/stylesheets/timeline_inspector.css b/app/assets/stylesheets/timeline_inspector.css new file mode 100644 index 000000000..526dbc6b8 --- /dev/null +++ b/app/assets/stylesheets/timeline_inspector.css @@ -0,0 +1,296 @@ +/* ========================= + TIMELINE INSPECTOR PANEL + Modern Details & Linking UI + ========================= */ + +/* Inspector Panel Container */ +.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% + ); +} + +/* Inspector Header */ +.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% + ); +} + +/* Event Title in Inspector */ +.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; +} + +/* Metadata Badges */ +.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); +} + +/* Description Panel */ +.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); +} + +/* Quick Action Buttons */ +.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%; +} + +/* Link Content Button */ +.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); +} + +/* Delete Button */ +.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); +} + +/* Empty State Styling */ +.timeline-inspector .text-gray-500 { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Linked Content Info Box */ +.timeline-inspector .bg-gray-50.rounded-lg { + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid #e2e8f0; + border-left: 3px solid #10b981; +} + +/* Section Dividers */ +.timeline-inspector .border-t, +.timeline-inspector .border-b { + border-color: #f1f5f9; +} + +/* Scrollbar Styling */ +.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); +} + +/* Mobile Responsive */ +@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; + } + + /* Mobile backdrop */ + .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; + } +} + +/* Focus States */ +.timeline-inspector button:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +/* Loading States */ +.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; +} + +/* Subtle pattern overlay */ +.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; +} + +/* Animation for content changes */ +.timeline-inspector [x-show] { + transition: all 0.2s ease-out; +} + +/* Enhanced section headers */ +.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; +} \ No newline at end of file diff --git a/app/assets/stylesheets/timeline_linked_content.css b/app/assets/stylesheets/timeline_linked_content.css new file mode 100644 index 000000000..b84e255c0 --- /dev/null +++ b/app/assets/stylesheets/timeline_linked_content.css @@ -0,0 +1,264 @@ +/* ========================= + TIMELINE LINKED CONTENT + Paper-Style Card Integration + ========================= */ + +/* Horizontal Scrolling Container */ +.linked-content-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.3) transparent; +} + +/* Contained scrolling for linked content */ +.linked-content-breakout { + /* Stay within parent container bounds */ + 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 Utilities */ +.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 Styling */ +.linked-content-card { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 12px; + background: white; + border: 1px solid #f3f4f6; + 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); + border-color: #e5e7eb; +} + +.linked-content-card:hover::before { + opacity: 1; +} + +/* Image Placeholder Gradient */ +.image-placeholder { + background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%); +} + +/* Modern Badge Styles */ +.type-badge { + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(229, 231, 235, 0.8); + 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 { + background: rgba(255, 255, 255, 1); + 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); +} + +/* Modern Remove Button */ +.remove-btn { + transition: all 0.2s ease-in-out; + background: rgba(255, 255, 255, 0.95); + border: 1px solid rgba(229, 231, 235, 0.8); + 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); + background: #fef2f2; + border-color: #fecaca; + color: #dc2626; + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +/* Clean Name Card Overlay */ +.name-card-overlay { + background: white; + border: 1px solid #e5e7eb; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +/* Refined Gradient Overlay */ +.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; +} + +/* Enhanced Animation for new cards */ +@keyframes slideInFromRight { + 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: slideInFromRight 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Enhanced Card Hover States */ +.linked-content-card:hover .name-card-overlay { + background: rgba(255, 255, 255, 0.98); + 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); +} + +/* Paper texture for linked content */ +.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; +} + +/* Text Selection Prevention */ +.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; +} + +/* Responsive adjustments */ +@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; + } +} + +/* Enhanced Focus states for accessibility */ +.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; +} \ No newline at end of file diff --git a/app/assets/stylesheets/timeline_sidebar.css b/app/assets/stylesheets/timeline_sidebar.css new file mode 100644 index 000000000..4df61a872 --- /dev/null +++ b/app/assets/stylesheets/timeline_sidebar.css @@ -0,0 +1,377 @@ +/* ========================= + TIMELINE SIDEBAR STYLING + Modern Tool Panel Design + ========================= */ + +/* Sidebar Container */ +.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% + ); +} + +/* Sidebar Header */ +.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% + ); +} + +/* Section Styling */ +.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); +} + +/* Search Input Styling */ +.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; +} + +/* Search Icon Styling */ +.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; +} + +/* Quick Action 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%; +} + +/* Primary Button (Add Event) */ +.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); +} + +/* Secondary Button (Import Events) */ +.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; +} + +/* Stats Section Styling */ +.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; +} + +/* Stats Text Styling */ +.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; +} + +/* Section 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; +} + +/* Scrollbar Styling */ +.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); +} + +/* Mobile Responsive */ +@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; + } + + /* Mobile backdrop */ + .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; + } +} + +/* Focus and Accessibility */ +.timeline-sidebar button:focus { + outline: 2px solid #10b981; + outline-offset: 2px; +} + +.timeline-sidebar input:focus { + outline: none; +} + +/* Loading States */ +.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; +} + +/* Animation for sidebar reveal */ +@keyframes slideInFromLeft { + from { + transform: translateX(-100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.timeline-sidebar.animate-in { + animation: slideInFromLeft 0.3s ease-out; +} + +/* Subtle pattern overlay */ +.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; +} \ No newline at end of file diff --git a/app/assets/stylesheets/timeline_viewer.css b/app/assets/stylesheets/timeline_viewer.css new file mode 100644 index 000000000..08380d4c8 --- /dev/null +++ b/app/assets/stylesheets/timeline_viewer.css @@ -0,0 +1,282 @@ +/* Timeline Viewer Styles */ + +/* Timeline spine positioning and styling */ +.timeline-viewer-container { + position: relative; +} + +.timeline-viewer-container .timeline-spine { + left: 1.5rem; /* 6 * 0.25rem = 1.5rem (left-6) */ + width: 2px; +} + +/* Timeline dots and connections */ +.timeline-viewer-container .timeline-dot, +.timeline-viewer-container .timeline-header-dot { + width: 1.5rem; /* 6 * 0.25rem = 1.5rem (w-6) */ + height: 1.5rem; /* 6 * 0.25rem = 1.5rem (h-6) */ + 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: fadeInUp 0.6s ease-out 0.5s both; +} + +@keyframes fadeInUp { + 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%); /* 16:9 aspect ratio */ +} + +.aspect-h-9 > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Line clamping for text overflow */ +.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 print styles */ + .linked-content-group .overflow-x-auto { + overflow-x: visible; + } + + .linked-content-group .flex { + flex-wrap: wrap; + gap: 0.75rem; + } +} + +/* Animation for content loading */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.timeline-event-container { + animation: fadeInUp 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 { + /* Custom card dimensions */ + --card-width: 12rem; /* 192px - w-48 */ + --card-height: 7.5rem; /* 120px - h-30 */ +} + +.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; /* Smaller on mobile */ + --card-height: 6rem; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/topic-show.scss b/app/assets/stylesheets/topic-show.scss new file mode 100644 index 000000000..4e3f763e3 --- /dev/null +++ b/app/assets/stylesheets/topic-show.scss @@ -0,0 +1,409 @@ +// 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 1.5rem; + + // Posts container + .posts-container { + margin-bottom: 2rem; + } +} + +// Individual post styling - Modern chat bubble design +.thredded--post { + margin-bottom: 1.5rem; + 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, + ol { + margin: 0.75rem 0; + padding-left: 1.5rem; + } + + 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 +.reply-form-section { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + margin-top: 2rem; + + .reply-form-header { + padding: 1.25rem 1.5rem; + 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: 1.5rem; + } +} + +// Pagination styling +.topic-pagination { + display: flex; + justify-content: center; + margin: 1.5rem 0; + + .pagination { + display: flex; + 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: #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; + } + } +} + +// ============================================= +// 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/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/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..c52c5a262 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -79,6 +79,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 @@ -157,6 +158,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 + + # Count edits for each content page using ContentChangeEvent + content_with_edit_counts = all_content.map do |content_page| + edit_count = ContentChangeEvent.where( + content_type: content_page.page_type, + content_id: content_page.id, + user_id: current_user.id + ).count + + [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,6 +214,8 @@ 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 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..ebf261b0f 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 @@ -102,8 +285,10 @@ def content_param_list :label, :description, :entity_type, :attribute_category_id, - :hidden, - field_options: {} + :hidden, :position, + field_options: { + linkable_types: [] + } ] end diff --git a/app/controllers/basil_controller.rb b/app/controllers/basil_controller.rb index fdec076b0..b8310a7f1 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,7 +24,14 @@ def index def content # Fetch the content page from our already-queried cache of current user content @content_type = params[:content_type].humanize - @content = @current_user_content[@content_type].detect do |page| + + # 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[@content_type]&.detect do |page| page.id == params[:id].to_i end raise "No content found for #{params[:content_type]} with ID #{params[:id]} for user #{current_user.id}" if @content.nil? @@ -208,6 +216,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) @@ -667,6 +782,23 @@ def delete 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 diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb index dc138aa56..bc6bd27a0 100644 --- a/app/controllers/content_controller.rb +++ b/app/controllers/content_controller.rb @@ -3,7 +3,7 @@ # 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: [ @@ -12,9 +12,9 @@ class ContentController < ApplicationController before_action :migrate_old_style_field_values, only: [:show, :edit] - before_action :cache_linkable_content_for_each_content_type, only: [:new, :edit, :index] + before_action :cache_linkable_content_for_each_content_type, only: [:new, :show, :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 +22,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 +36,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 +54,67 @@ 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 + 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] }.reverse + else # 'updated_at' or default + @content.sort_by { |x| [x.favorite? ? 0 : 1, x.updated_at] }.reverse + 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 +123,83 @@ 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) - + # 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 + if (current_user || User.new).can_read?(@content) respond_to do |format| format.html { render 'content/show', locals: { content: @content } } @@ -113,6 +210,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) + + 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) @@ -335,6 +455,26 @@ def changelog @serialized_content = ContentSerializer.new(@content) 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 = 20 # 20 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 +554,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,32 +626,47 @@ 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) @@ -641,7 +805,10 @@ 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_field.label} updated!") } + format.json { render json: attribute_value.to_json, status: 200 } + end end def universe_field_update @@ -667,6 +834,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) @@ -813,42 +994,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 +1001,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..410647753 100644 --- a/app/controllers/content_page_shares_controller.rb +++ b/app/controllers/content_page_shares_controller.rb @@ -4,6 +4,7 @@ class ContentPageSharesController < ApplicationController :show, :edit, :update, :destroy, :follow, :unfollow, :report ] + before_action :load_recent_forum_topics, only: [:show] # GET /content_page_shares def index @@ -15,6 +16,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 @@ -86,4 +96,21 @@ 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) + .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..b446ed222 100644 --- a/app/controllers/data_controller.rb +++ b/app/controllers/data_controller.rb @@ -108,7 +108,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 +123,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 diff --git a/app/controllers/document_analyses_controller.rb b/app/controllers/document_analyses_controller.rb index 08b974e91..cef569c5a 100644 --- a/app/controllers/document_analyses_controller.rb +++ b/app/controllers/document_analyses_controller.rb @@ -1,16 +1,47 @@ 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 + @document_analyses = DocumentAnalysis.all + end def show @navbar_actions = [] unless @analysis.present? diff --git a/app/controllers/document_revisions_controller.rb b/app/controllers/document_revisions_controller.rb index 7097c524d..803307fd3 100644 --- a/app/controllers/document_revisions_controller.rb +++ b/app/controllers/document_revisions_controller.rb @@ -1,6 +1,6 @@ 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 @@ -11,6 +11,270 @@ def index 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..9cba26e5c 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -1,4 +1,6 @@ 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. @@ -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,6 +103,7 @@ 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) } @@ -147,7 +195,40 @@ def link_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 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 else # If we pass in an actual ID for the document entity, we're modifying an existing one @@ -160,7 +241,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 +288,13 @@ 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) with their associations @linked_entities = @document.document_entities + .includes(:entity) .where.not(entity_id: nil) - .group_by(&:entity_type) + + # Group by entity type for easier rendering + @linked_entities_by_type = @linked_entities.group_by(&:entity_type) end def create @@ -200,12 +316,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 +341,22 @@ 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 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 destroy @@ -293,8 +421,31 @@ 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 + # Today's word count + @words_written_today = WordCountUpdate + .where(user: current_user, for_date: Date.current) + .sum(:word_count) + + # This week's word count + @words_written_this_week = WordCountUpdate + .where(user: current_user, for_date: Date.current.beginning_of_week..Date.current) + .sum(:word_count) + end + def update_page_tags(document) tag_list = document_tag_params.fetch('value', '').split(PageTag::SUBMISSION_DELIMITER) current_tags = document.page_tags.pluck(:tag) diff --git a/app/controllers/folders_controller.rb b/app/controllers/folders_controller.rb index 77d44ee56..a57e02dd2 100644 --- a/app/controllers/folders_controller.rb +++ b/app/controllers/folders_controller.rb @@ -16,61 +16,142 @@ 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 + # Today's word count + @words_written_today = WordCountUpdate + .where(user: current_user, for_date: Date.current) + .sum(:word_count) + + # This week's word count + @words_written_this_week = WordCountUpdate + .where(user: current_user, for_date: Date.current.beginning_of_week..Date.current) + .sum(:word_count) end private diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index 65ac6294d..d0c9b1655 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -1,11 +1,52 @@ 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" 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 set_sidenav_expansion @sidenav_expansion = 'my account' diff --git a/app/controllers/information_controller.rb b/app/controllers/information_controller.rb index decd69a02..257644166 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 diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb index e5248c55a..ed6c6c98b 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 @@ -31,12 +36,44 @@ def dashboard .order('id DESC') .limit(300) .shuffle - .first(3) + .first(8) # Increased from 3 to 8 for the ticker @most_recent_threads = Thredded::Topic.where(id: most_recent_posts.pluck(:postable_id)) .where(moderation_state: "approved") .includes(:posts, :messageboard) + # 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' + end + + def table_of_contents + @toc_scope = @universe_scope || current_user + @toc_user = @universe_scope.try(:user) || current_user + + # Get content list - handle differently depending on whether we're scoped to a universe or a user + content_list = if @toc_scope.is_a?(Universe) + # For a Universe, we need to get content from the user that's in this universe + user_content = @toc_user.content_list(page_scoping: { user_id: @toc_user.id }) + universe_id = @toc_scope.id + user_content.select { |page| page['universe_id']&.to_i == universe_id || page['page_type'] == 'Universe' && page['id'] == universe_id } + else + # For a User, we can directly use their content_list method + @toc_scope.content_list + end + + # Sort the content list by name + content_list = content_list.sort_by { |page| page['name'] } + + @starred_pages = content_list.select { |page| page['favorite'] == 1 } + @other_pages = content_list.select { |page| page['favorite'] == 0 } + + @page_type_counts = Hash.new(0) + content_list.each { |page| @page_type_counts[page['page_type']] += 1 } end def infostack @@ -84,6 +121,206 @@ 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 + + # Add enhanced data to each content item + @enhanced_content = all_content.map do |content_page| + edit_count = ContentChangeEvent.where( + content_type: content_page.page_type, + content_id: content_page.id, + user_id: current_user.id + ).count + + { + 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.random_image_including_private.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) || [] + + # 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 + all_content = @current_user_content.values.flatten + + # 30-Day Activity Chart for Dashboard + @dashboard_daily_activity = (0..29).map do |days_ago| + date = Date.current - days_ago.days + activity_count = all_content.count do |content_page| + content_page.updated_at.to_date == date + end + [date.strftime('%m/%d'), activity_count] + end.reverse + + # Calculate Editing Streak + calculate_editing_streak(all_content) + end + + def calculate_editing_streak(all_content) + # Get unique dates when user edited content (last 100 days to be safe) + edit_dates = all_content.map { |page| page.updated_at.to_date }.uniq.sort.reverse + + # Calculate current streak + current_streak = 0 + current_date = Date.current + + # Check each day going backwards + while edit_dates.include?(current_date) + current_streak += 1 + current_date -= 1.day + end + + @current_streak = current_streak + + # Calculate total edits in current streak + @streak_total_edits = 0 + if current_streak > 0 + streak_dates = (0..current_streak-1).map { |days_ago| Date.current - days_ago.days } + @streak_total_edits = all_content.count { |page| streak_dates.include?(page.updated_at.to_date) } + end + + # Generate last 7 days for streak visualization + @streak_days = (0..6).map do |days_ago| + date = Date.current - days_ago.days + has_activity = edit_dates.include?(date) + edit_count = all_content.count { |page| page.updated_at.to_date == date } + { + date: date, + has_activity: has_activity, + edit_count: edit_count, + day_name: date.strftime('%a')[0] # First letter of day name + } + end.reverse end def for_writers @@ -107,4 +344,11 @@ def set_questionable_content @content = @current_user_content.except(*%w(Timeline Document)).values.flatten.sample @attribute_field_to_question = SerendipitousService.question_for(@content) 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/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..3e1b4ec04 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -5,8 +5,19 @@ class RegistrationsController < Devise::RegistrationsController 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 +40,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,7 +58,7 @@ 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, 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..5b22927b8 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,23 @@ def set_stream_navbar_actions def set_sidenav_expansion @sidenav_expansion = 'community' end + + private + + 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) + .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 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/timeline_events_controller.rb b/app/controllers/timeline_events_controller.rb index 3f1ca7751..cd85a2577 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,54 @@ 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 } + ) + } + else + render json: { + status: 'error', + message: 'Failed to unlink content' + }, status: :unprocessable_entity + end end # Move functions @@ -81,6 +145,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 +246,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..005cf57d3 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] before_action :set_navbar_color before_action :set_sidenav_expansion @@ -27,8 +29,10 @@ def index 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)) end + @filtered_page_tags = [] @page_tags = PageTag.where( page_type: Timeline.name, page_id: @timelines.pluck(:id) @@ -41,6 +45,20 @@ def index # if params.key?(:favorite_only) # @timelines.select!(&:favorite?) # 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 @@ -57,6 +75,41 @@ 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 + all_content = content_class.where(id: entity_ids) + .where(user: current_user) + .order(:name) + + @timeline_content_summary[entity_type] = { + count: all_content.count, + items: all_content.limit(10), # Show first 10, with expansion option + total_items: all_content.count, + content_class: content_class + } + end end # POST /timelines @@ -90,6 +143,53 @@ def destroy redirect_to timelines_url, notice: 'Timeline was successfully destroyed.' end + # GET /timelines/1/toggle_archive + def toggle_archive + unless @timeline.updatable_by?(current_user) + flash[:notice] = "You don't have permission to edit that!" + return redirect_back fallback_location: @timeline + end + + verb = nil + success = if @timeline.archived? + verb = "unarchived" + @timeline.unarchive! + else + verb = "archived" + @timeline.archive! + end + + if success + flash[:notice] = "Timeline #{verb} successfully!" + else + flash[:alert] = "Failed to #{verb.sub('ed', '')} timeline." + end + + redirect_back fallback_location: edit_timeline_path(@timeline) + 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 +242,16 @@ 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 + .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..073a301cf 100644 --- a/app/controllers/universes_controller.rb +++ b/app/controllers/universes_controller.rb @@ -1,4 +1,7 @@ class UniversesController < ContentController + def hub + @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..e90550e27 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| @@ -32,18 +25,25 @@ def show return if @user.nil? return if @user.private_profile? - @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) + .is_public + .order(:name) + .paginate(page: params[:page], per_page: 20) + + # Only load 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) + .group_by { |image| [image.content_type, image.content_id] } - @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] } + # 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 @@ -204,6 +204,153 @@ 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) + else + @is_following = false + @is_blocked = false + end + end + + def load_user_collections + # Collections user maintains + @maintained_collections = @user.page_collections.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/helpers/application_helper.rb b/app/helpers/application_helper.rb index cd32a1c70..addfc3f7e 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 { |img| img.pinned == true } + pinned_basil = basil_images.find { |img| img.pinned == true } # 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..3b3a8f1f9 100644 --- a/app/helpers/attributes_helper.rb +++ b/app/helpers/attributes_helper.rb @@ -13,4 +13,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/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/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/packs/application.js b/app/javascript/packs/application.js index 53484ee1e..800690598 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -15,6 +15,20 @@ // const images = require.context('../images', true) // const imagePath = (name) => images(name, true) +import "../application.css"; +import 'controllers' +import '../page_name_loader' +import '../settings' + +// Import Rails UJS for remote forms and CSRF tokens +// Check if jQuery UJS is already loaded via asset pipeline before loading @rails/ujs +import Rails from '@rails/ujs' +if (typeof $ === 'undefined' || typeof $.rails === 'undefined') { + Rails.start() +} else { + console.log('jQuery UJS already loaded via asset pipeline, skipping @rails/ujs') +} + // 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..24851b8a1 --- /dev/null +++ b/app/javascript/settings.js @@ -0,0 +1,426 @@ +// 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 + initializeTooltips(); +}); + +// Initialize toggle switches to replace checkboxes +function initializeToggleSwitches() { + document.querySelectorAll('.toggle-switch').forEach(function(toggle) { + toggle.addEventListener('click', function(e) { + if (e.target.tagName !== 'INPUT') { + const checkbox = this.querySelector('input[type="checkbox"]'); + 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 + const checkbox = toggle.querySelector('input[type="checkbox"]'); + 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'); + }); + } +} + +// Initialize tooltips +function initializeTooltips() { + document.querySelectorAll('[data-tooltip]').forEach(element => { + const tooltipText = element.getAttribute('data-tooltip'); + + element.addEventListener('mouseenter', function(e) { + const tooltip = document.createElement('div'); + tooltip.className = 'absolute z-10 px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-sm tooltip'; + tooltip.textContent = tooltipText; + tooltip.style.top = `${e.target.offsetTop - 40}px`; + tooltip.style.left = `${e.target.offsetLeft + (e.target.offsetWidth / 2) - 80}px`; + + document.body.appendChild(tooltip); + + // Position tooltip + const rect = tooltip.getBoundingClientRect(); + if (rect.left < 0) { + tooltip.style.left = '0px'; + } else if (rect.right > window.innerWidth) { + tooltip.style.left = `${window.innerWidth - rect.width - 10}px`; + } + + // Add arrow + const arrow = document.createElement('div'); + arrow.className = 'tooltip-arrow'; + arrow.style.position = 'absolute'; + arrow.style.width = '10px'; + arrow.style.height = '10px'; + arrow.style.background = '#1F2937'; + arrow.style.transform = 'rotate(45deg)'; + arrow.style.bottom = '-5px'; + arrow.style.left = 'calc(50% - 5px)'; + tooltip.appendChild(arrow); + }); + + element.addEventListener('mouseleave', function() { + const tooltip = document.querySelector('.tooltip'); + if (tooltip) { + document.body.removeChild(tooltip); + } + }); + }); +} \ No newline at end of file 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/models/concerns/has_attributes.rb b/app/models/concerns/has_attributes.rb index c69dd5f71..bae7c9de9 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) diff --git a/app/models/concerns/has_content.rb b/app/models/concerns/has_content.rb index c12f6f4cf..664f76f2e 100644 --- a/app/models/concerns/has_content.rb +++ b/app/models/concerns/has_content.rb @@ -157,5 +157,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..1ce4cc8f0 100644 --- a/app/models/concerns/has_image_uploads.rb +++ b/app/models/concerns/has_image_uploads.rb @@ -8,6 +8,11 @@ module HasImageUploads # 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 public_image_uploads self.image_uploads.where(privacy: 'public').presence || [header_asset_for(self.class.name)] end @@ -30,17 +35,23 @@ def random_image_including_private(format: :medium) # 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) @@ -70,7 +81,17 @@ def pinned_image_upload(format = :medium) # 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 @@ -83,7 +104,17 @@ def pinned_public_image(format = :medium) 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 @@ -104,9 +135,14 @@ def header_asset_for(class_name) # 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") + asset_filename = "card-headers/#{class_name.downcase.pluralize}.webp" + + result = Rails.env.production? ? + "https://www.notebook.ai" + ActionController::Base.helpers.asset_url(asset_filename) : + ActionController::Base.helpers.asset_path(asset_filename) + + # Ensure we never return nil - provide a fallback + result.presence || (Rails.env.production? ? "https://www.notebook.ai/assets/#{asset_filename}" : "/assets/#{asset_filename}") end end end diff --git a/app/models/documents/document.rb b/app/models/documents/document.rb index 684c97168..8f92c3fca 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 @@ -16,6 +17,7 @@ class Document < ApplicationRecord include HasImageUploads include HasPageTags include BelongsToUniverse + include HasPrivacy belongs_to :folder, optional: true @@ -35,11 +37,11 @@ def latest_word_count_cache KEYS_TO_TRIGGER_REVISION_ON_CHANGE = %w(title body synopsis notes_text) 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 +80,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 +100,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 diff --git a/app/models/documents/document_analysis.rb b/app/models/documents/document_analysis.rb index 6f6669841..df3276ffc 100644 --- a/app/models/documents/document_analysis.rb +++ b/app/models/documents/document_analysis.rb @@ -24,4 +24,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..39d1ec3a9 100644 --- a/app/models/documents/document_entity.rb +++ b/app/models/documents/document_entity.rb @@ -1,6 +1,7 @@ class DocumentEntity < ApplicationRecord belongs_to :entity, polymorphic: true, optional: true belongs_to :document_analysis, optional: true + has_one :document, through: :document_analysis after_create :match_notebook_page!, if: Proc.new { |de| de.entity_id.nil? } diff --git a/app/models/folder.rb b/app/models/folder.rb index 2d9d7bb76..64a6c99bf 100644 --- a/app/models/folder.rb +++ b/app/models/folder.rb @@ -9,11 +9,15 @@ def child_folders 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_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..819df89fb 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,12 @@ 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 icon self.page_type.constantize.icon end @@ -43,7 +50,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..930252800 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 @@ -75,11 +76,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 +94,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/content_serializer.rb b/app/models/serializers/content_serializer.rb index 3abafb1a7..92ab1920e 100644 --- a/app/models/serializers/content_serializer.rb +++ b/app/models/serializers/content_serializer.rb @@ -8,9 +8,10 @@ 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 # name: 'blah, @@ -41,12 +42,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), diff --git a/app/models/timelines/timeline.rb b/app/models/timelines/timeline.rb index e12d64495..0df5bfd87 100644 --- a/app/models/timelines/timeline.rb +++ b/app/models/timelines/timeline.rb @@ -22,11 +22,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 :( diff --git a/app/models/timelines/timeline_event.rb b/app/models/timelines/timeline_event.rb index 9cbe6c9eb..5366ca574 100644 --- a/app/models/timelines/timeline_event.rb +++ b/app/models/timelines/timeline_event.rb @@ -4,9 +4,72 @@ class TimelineEvent < ApplicationRecord belongs_to :timeline, touch: true has_many :timeline_event_entities, dependent: :destroy + + include HasPageTags acts_as_list scope: [:timeline_id] + # 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 } + + # Helper methods + def event_type_info + EVENT_TYPES[event_type] || EVENT_TYPES['general'] + 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) user == timeline.user diff --git a/app/models/users/user.rb b/app/models/users/user.rb index e44a506d6..e6d838668 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -51,19 +51,19 @@ def referrer has_many :followed_users, -> { distinct }, through: :user_followings, source: :followed_user # 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 +93,8 @@ 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 :page_settings_overrides, dependent: :destroy has_one_attached :avatar validates :avatar, attached: false, @@ -182,6 +184,10 @@ def createable_content_types Rails.application.config.content_types[:all].select { |c| can_create? c } end + def words_written_today + word_count_updates.where(created_at: Time.current.beginning_of_day..Time.current.end_of_day).sum(:word_count) + 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 @@ -355,11 +361,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..4add2c33f 100644 --- a/app/models/users/user_following.rb +++ b/app/models/users/user_following.rb @@ -1,4 +1,4 @@ class UserFollowing < ApplicationRecord - belongs_to :user - belongs_to :followed_user, class_name: User.name + belongs_to :user, counter_cache: :following_count + belongs_to :followed_user, class_name: User.name, counter_cache: :followers_count 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..4a784e148 --- /dev/null +++ b/app/services/changelog_stats_service.rb @@ -0,0 +1,148 @@ +# 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 past 12 weeks + weeks = [] + (0..11).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/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/views/basil/content.html.erb b/app/views/basil/content.html.erb index c6ecfed0f..d72896b7e 100644 --- a/app/views/basil/content.html.erb +++ b/app/views/basil/content.html.erb @@ -1,447 +1,706 @@ - - -
    - <%= 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 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 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' %> +
      + <% 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' %> - + + +
    + <% 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 %> +
    +
    + + + -
    -
      - <% @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 @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 -

    + +
    +

    + lightbulb + Suggested Fields to Get Started +

    +
    + <% @suggested_fields.each do |field_name, section| %> +
    + edit +
    + <%= field_name %> + <%= section %> +
    +
    + <% end %> +
    +
    - 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 %>. -
    -
    + + <%= 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 %> - +

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

    +
    +
    + <% 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 %> +
    +
    + <% 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 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: -
    -
    -
    - <% 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' %> + + <% unless @relevant_fields.empty? %> +
    +
    + <% if @commissions.any? %> +

    + collections + Generated Images +

    <% 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 %>) - <% 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| %> -
    - -
    -
    - -
    -
    - + + +
    + <% @commissions.each do |commission| %> + <% if commission.complete? %> + +
    +
    + <%= link_to commission.image, target: '_blank', class: 'block' do %> + <%= image_tag commission.image, class: 'w-full h-auto' %> +
    + zoom_in
    -
    - + <% end %> +
    + +
    +
    +
    +

    <%= @content.name %>

    +

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

    -
    - +
    + + + <%= form_for commission.basil_feedbacks.find_or_initialize_by(user: current_user), + url: basil_feedback_path(commission.job_id), + method: :POST, + remote: true, + html: { class: 'feedback-form', data: { commission_id: commission.id } } do |f| %> +
    + <% [ + [-2, 'sentiment_very_dissatisfied', 'text-red-500'], + [-1, 'sentiment_dissatisfied', 'text-orange-500'], + [1, 'sentiment_satisfied', 'text-green-500'], + [2, 'sentiment_very_satisfied', 'text-green-600'], + [3, 'favorite', 'text-red-500'] + ].each do |score, icon, 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 %> + +
    +
    +
    +
    +
    +
    +

    Generating...

    +

    + <%= 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 %> -
    - - <% 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 %>
    - <% end %> -
    +
    + <% end %>
    -<% content_for :javascript do %> -$(document).ready(function() { - $('.js-save-commission').click(function(e) { - $(this).text('Saved!'); - - var save_endpoint = $(this).data('endpoint') + '.json'; - $.post(save_endpoint, function(data) { - console.log(data); + -<% content_for :css_includes do %> - -<% end %> \ No newline at end of file +/* 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/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..7e06ff913 --- /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-enhanced-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..3fce57400 --- /dev/null +++ b/app/views/content/_header.html.erb @@ -0,0 +1,90 @@ + diff --git a/app/views/content/attributes.html.erb b/app/views/content/attributes.html.erb index d3da2a4cf..b30ee23b4 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/tailwind/_category_card.html.erb b/app/views/content/attributes/tailwind/_category_card.html.erb new file mode 100644 index 000000000..31e0f25f5 --- /dev/null +++ b/app/views/content/attributes/tailwind/_category_card.html.erb @@ -0,0 +1,228 @@ +
    " + <% if category.hidden? %>style="display: none;"<% end %>> + + +
    > + +
    + + + +
    + + +
    + ><%= category.icon %> +
    + + +
    +

    + <%= category.label %> + <% if category.hidden? %> + archive + <% end %> +

    +

    + <%= pluralize(category.attribute_fields.count, 'field') %> + <% if category.hidden? %> + — Archived + <% end %> +

    +
    + + +
    + + + + + + + + + + +
    +
    + + +
    + + +
    + <% category.attribute_fields.order(:position).each do |field| %> + <%= render partial: 'content/attributes/tailwind/field_item', locals: { + field: field, + content_type_class: content_type_class, + content_type: content_type + } %> + <% end %> +
    + + +
    + + + +
    +
    + + +
    + + +
    + <%= form_for(category.attribute_fields.build, method: :post, html: { class: "space-y-3", 'data-type': 'json', 'data-remote': 'true', '@submit.prevent': 'submitFieldForm($event)' }, remote: true) do |f| %> + <%= f.hidden_field :attribute_category_id, value: category.id %> + <%= f.hidden_field :field_type, value: 'text_area' %> + +
    + + <%= f.text_field :label, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md js-field-input 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 'Add Text 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 %> + +
    +
    Field Suggestions
    +
    + +
    +
    + + +
    + <%= form_for(category.attribute_fields.build, method: :post, html: { class: "space-y-3", 'data-type': 'json', 'data-remote': 'true', '@submit.prevent': 'submitFieldForm($event)' }, remote: true) do |f| %> + <%= f.hidden_field :attribute_category_id, value: category.id %> + <%= f.hidden_field :field_type, value: 'link' %> + +
    + + <%= f.text_field :label, class: "shadow-sm block w-full sm:text-sm border-gray-300 rounded-md js-field-input 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};" %> +
    + +
    +
    + +
    + + +
    +
    + + + + +
    + <% 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..bbf4e4f80 --- /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 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 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 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 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 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 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..c78ec380b --- /dev/null +++ b/app/views/content/attributes/tailwind/_field_config.html.erb @@ -0,0 +1,488 @@ +
    +
    +

    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 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| %> +
    + + + +
    + + + + + +
    + + +
    + +
    + +
    +
    • Alice Johnson
    +
    • The Rusty Tavern
    +
    • Magic Sword
    +
    + + +
    +
    + + <%= defined?(Character) ? Character.icon : 'person' %> + Alice Johnson + + + <%= defined?(Location) ? Location.icon : 'place' %> + The Rusty Tavern + + + <%= defined?(Item) ? Item.icon : 'category' %> + 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 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 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 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 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 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..71e339a30 --- /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..8950c6b78 --- /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 hover:text-blue-500 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..3069ebf33 100644 --- a/app/views/content/changelog.html.erb +++ b/app/views/content/changelog.html.erb @@ -1,5 +1,20 @@ -<%= content_for :full_width_page_header do %> - <%= render partial: 'content/display/image_card_header' %> -<% end %> + +
    + + + <%= 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' %> + +
    -<%= render partial: 'content/display/changelog', locals: { content: @serialized_content } %> + + \ 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..091f01a73 --- /dev/null +++ b/app/views/content/changelog/_date_changes.html.erb @@ -0,0 +1,129 @@ + +<% + # 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..7545d3908 --- /dev/null +++ b/app/views/content/changelog/_header.html.erb @@ -0,0 +1,133 @@ + +
    +
    + + + + +
    +
    +
    + <%= @serialized_content.class_icon %> +
    +
    +

    + Your Creative Journey +

    +

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

    +
    +
    +
    + + +
    + +
    +
    +
    + edit +
    +
    +
    <%= pluralize(@stats.total_changes, 'change') %>
    +
    Total
    +
    +
    +
    + + +
    +
    +
    + calendar_today +
    +
    +
    <%= pluralize(@stats.active_days, 'day') %>
    +
    Active
    +
    +
    +
    + + +
    +
    +
    + schedule +
    +
    +
    <%= pluralize(@stats.days_since_creation, 'day') %>
    +
    Old
    +
    +
    +
    + + +
    +
    +
    + trending_up +
    +
    + <% biggest_update = @stats.biggest_single_update %> + <% field_count = biggest_update ? biggest_update[:field_count] : 0 %> +
    + <%= pluralize(field_count, 'change') %> +
    +
    Biggest Edit Session
    +
    +
    +
    +
    + + +
    +

    + show_chart + Activity Over Time +

    + +
    + <% @change_intensity.each do |week_data| %> + <% + intensity_level = case week_data[:change_count] + when 0 then 'bg-gray-100' + when 1..3 then 'bg-green-200' + when 4..7 then 'bg-green-400' + when 8..15 then 'bg-green-600' + else 'bg-green-800' + end + %> +
    +
    + <% end %> +
    + +
    + 12 weeks ago + Today +
    +
    + + + <% 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..d7bbf97c7 --- /dev/null +++ b/app/views/content/changelog/_timeline.html.erb @@ -0,0 +1,188 @@ + +
    + + +
    +

    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" %> + <% 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-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-gray-700" do %> + chevron_left + Previous + <% end %> + <% end %> + +
    + Page <%= @paginated_events.current_page %> of <%= @paginated_events.total_pages %> +
    + + <% if @paginated_events.next_page %> + <%= link_to url_for(params.permit(:page).merge(page: @paginated_events.next_page)), + class: "flex items-center px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-gray-700" do %> + Next + chevron_right + <% end %> + <% end %> +
    +
    +
    + <% end %> + +
    +
    \ 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..0bf5f99cc 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..f938fdaeb 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 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 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..3b5f1571b 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..8b87c856e 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/_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/_tailwind_foldered_index.html.erb b/app/views/content/display/_tailwind_foldered_index.html.erb new file mode 100644 index 000000000..d02b8d8e8 --- /dev/null +++ b/app/views/content/display/_tailwind_foldered_index.html.erb @@ -0,0 +1,1187 @@ +<% + 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 + + if @folders.any? + content_counts_per_type[Folder.name] = @folders.count + 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 %> + <%= image_tag current_user.image_url, class: 'h-16 w-16 rounded-xl ring-4 ring-white shadow-lg lg:h-20 lg:w-20' %> + <% 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? %> + +
    + +
    +
    + <%= link_to attribute_customization_path(content_type_name.downcase), class: "group flex items-center px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white" do %> + tune + Customize template + <% end %> +
    +
    +
    + <% 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 +
    + +
    + +
    + + +
    + Press ⌘K to focus search +
    +
    + + +
    + +
    +
    + <%= link_to url_for(params.permit(:slug, :favorite_only).merge({ sort: 'updated_at' })), class: "block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 #{'bg-gray-100 dark:bg-gray-600 font-medium' if params[:sort] == 'updated_at' || params[:sort].blank?}" do %> + Recently edited + <% end %> + <%= link_to url_for(params.permit(:slug, :favorite_only).merge({ sort: 'alphabetical' })), class: "block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 #{'bg-gray-100 dark:bg-gray-600 font-medium' if params[:sort] == 'alphabetical'}" do %> + Alphabetical + <% end %> + <%= link_to url_for(params.permit(:slug, :favorite_only).merge({ sort: 'created_at' })), class: "block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 #{'bg-gray-100 dark:bg-gray-600 font-medium' if params[:sort] == 'created_at'}" do %> + Date created + <% end %> +
    +
    +
    + + + <% 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? %> + +
    +
    +
    + <% if @page_tags && @page_tags.any? %> + <% @page_tags.each do |page_tag| %> +
    + + value="<%= page_tag.slug %>" + type="checkbox" + class="h-4 w-4 border-gray-300 rounded text-blue-600 focus:ring-blue-500" + > + +
    + <% end %> +
    + +
    + <% else %> +
    + label_outline +

    No tags yet

    +

    Add tags to your <%= @content_type_name.downcase.pluralize %> to filter by them

    +
    + <% end %> +
    +
    +
    + <% end %> +
    + <% end %> +
    + +
    + + + + + <% if (@total_content_count || 0) > 0 %> + + + <% else %> + No items + <% end %> + +
    +
    + + + <% if @filtered_page_tags && @filtered_page_tags.any? %> +
    + Filtered by: + <% @filtered_page_tags.each do |page_tag| %> + + <%= 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 content.any? %> + + + + +
    +
    + <% content.each do |item| %> +
    +
    + + <%= link_to (item.is_a?(ContentPage) ? item.view_path : item), class: "block" do %> + <% + item_image = nil + if item.respond_to?(:random_image_including_private) + item_image = item.random_image_including_private(format: :medium) + 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 %> +
    +
    + + + + <% 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 %> +
    +
    +
    + + <% 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 %> +
    + + +
    +
    + <% 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..72005e6b7 100644 --- a/app/views/content/edit.html.erb +++ b/app/views/content/edit.html.erb @@ -1,54 +1,778 @@ -<% 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/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' %> + + + 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; + } + }, -<% if @content.persisted? %> - <%= render partial: 'content/share', locals: { shared_content: @content} %> -<% end %> + 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 (< 1280px) + 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); + } + } + } +} + +// Delete confirmation function +function confirmDelete(pageName, deletePath) { + // Create a more robust confirmation dialog + const confirmed = confirm(`⚠️ WARNING: This action cannot be undone!\n\nAre you absolutely sure you want to permanently delete "${pageName}"?\n\nAll associated data, images, and links will be lost forever.`); + + if (confirmed) { + // Second confirmation for extra safety + const doubleConfirmed = confirm(`This is your FINAL warning!\n\nType OK to permanently delete "${pageName}".`); + + if (doubleConfirmed) { + // Create a form and submit it with DELETE method + const form = document.createElement('form'); + form.method = 'POST'; + form.action = deletePath; + + // Add CSRF token + const csrfToken = document.querySelector('meta[name="csrf-token"]').content; + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'authenticity_token'; + csrfInput.value = csrfToken; + form.appendChild(csrfInput); + + // Add method override for DELETE + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = 'delete'; + form.appendChild(methodInput); + + // Append to body and submit + document.body.appendChild(form); + form.submit(); + } + } +} + +// 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) { + pageHeader.textContent = newName; + } + } + } + }); +}); + \ No newline at end of file diff --git a/app/views/content/edit/_all_categories.html.erb b/app/views/content/edit/_all_categories.html.erb new file mode 100644 index 000000000..156ecc04a --- /dev/null +++ b/app/views/content/edit/_all_categories.html.erb @@ -0,0 +1,197 @@ +
    + <% content.data[:categories].each do |category| %> + <% next if category[:label] == 'Gallery' %> + <% next if category[:label] == 'Contributors' && raw_content.class.name == 'Universe' %> + +
    +
    +
    +
    + <%= category[:icon] %> +

    <%= category[:label] %>

    +
    + + + <% if category[:percent_complete].present? && category[:percent_complete] > 0 %> +
    + <%= category[:percent_complete] %>% complete +
    +
    +
    +
    + <% end %> +
    +
    + +
    + <% 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 %> +
    +
    + <% end %> +
    + + + + \ No newline at end of file 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..e2a390698 --- /dev/null +++ b/app/views/content/edit/_dynamic_content.html.erb @@ -0,0 +1,588 @@ +
    + + +
    + + +
    +
    +

    Edit All Details

    +

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

    +
    + + <%= render partial: 'content/edit/all_categories', locals: { content: content, raw_content: raw_content } %> +
    + + +
    + <% content.data[:categories].each do |category| %> + <% next if category[:label] == 'Gallery' %> + +
    +
    +
    +
    + <%= category[:icon] %> +
    +

    Edit <%= category[:label] %>

    +

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

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

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

    + <% end %> +
    + + <%= render partial: 'content/edit/single_category', locals: { + content: content, + category: category, + raw_content: raw_content + } %> +
    + <% end %> +
    + + +
    + <% first_category = content.data[:categories].find { |cat| cat[:label] != 'Gallery' } %> + <% if first_category %> +
    +
    +
    +
    + <%= first_category[:icon] %> +
    +

    Edit <%= first_category[:label] %>

    +

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

    +
    +
    +
    +
    + + <%= render partial: 'content/edit/single_category', locals: { + content: content, + category: first_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

    +

    Control who can see and edit 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-4" } do |f| %> + + + + + + <% end %> + + +
    +

    + tune + Additional Options +

    + +
    + +
    +
    + key +
    +
    +
    Password Protected
    +
    Require a password to view this private page when shared
    +
    + Coming Soon +
    +
    +
    + + +
    +

    + tune + Additional Options +

    + +
    + +
    +
    + search +
    +
    +
    Discoverable
    +
    Appears in public searches and content indexes
    +
    + Coming 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 %>

    +
    + +
    + +
    +

    Archive Page

    + +
    +

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

    +

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

    +
    + <%= link_to polymorphic_path(raw_content), + method: :delete, + data: { + confirm: "Are you sure you want to archive #{raw_content.name}? You can restore it later from your archives.", + 'soft-delete': true + }, + class: "inline-flex items-center px-4 py-2 border border-yellow-600 dark:border-yellow-500 text-sm font-medium rounded-md text-yellow-700 dark:text-yellow-300 bg-white dark:bg-gray-700 hover:bg-yellow-50 dark:hover:bg-yellow-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500" do %> + archive + Archive this page + <% end %> + + <%= link_to recently_deleted_content_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..67e40bc14 --- /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..85d8c19ff --- /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..8edaff3bd --- /dev/null +++ b/app/views/content/edit/_gallery_panel.html.erb @@ -0,0 +1,522 @@ +<% + # 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 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', + render_options: { locals: { f: f, content: content }} do %> + add_photo_alternate + Add another image + <% end %> + + <%= f.button :submit, + class: '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..bc742a9b6 --- /dev/null +++ b/app/views/content/edit/_navigation_sidebar.html.erb @@ -0,0 +1,129 @@ +
    + + + + +
    + + +
    +
    +

    Edit Sections

    + +
    + + +
    + + +
    +

    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..cb456c9c4 --- /dev/null +++ b/app/views/content/edit/_secondary_sidebar.html.erb @@ -0,0 +1,172 @@ + +
    +
    + + +
    +
    + <% 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 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 %>% +
    +
    +
    +
    +
    +
    +
    +
    + + +
    +

    Keyboard Shortcuts

    +
    +
    + Save current field + ⌘S +
    +
    + Exit editing + Esc +
    +
    + Navigate fields + Tab +
    +
    +
    +
    +
    + + \ No newline at end of file diff --git a/app/views/content/edit/_single_category.html.erb b/app/views/content/edit/_single_category.html.erb new file mode 100644 index 000000000..5f185b3f8 --- /dev/null +++ b/app/views/content/edit/_single_category.html.erb @@ -0,0 +1,174 @@ +
    +

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

    + + <% 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 %> +
    + + + + \ No newline at end of file diff --git a/app/views/content/form/_rich_text_input.html.erb b/app/views/content/form/_rich_text_input.html.erb index 435f881de..60119b33e 100644 --- a/app/views/content/form/_rich_text_input.html.erb +++ b/app/views/content/form/_rich_text_input.html.erb @@ -22,18 +22,26 @@ 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-l-4 border-r-4 border-gray-200 dark:border-gray-700 rounded-md resize-y bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100" \ + + " js-can-mention-pages js-autosize-textarea" \ + + "#{' autocomplete js-autocomplete-' + field[:id].to_s if should_autocomplete}" \ + + "#{' autosave-closest-form-on-change' if should_autosave}", + placeholder: placeholder, + rows: 2, + "data-enable-linking": "true" + %> + + + <%= render partial: 'javascripts/content_linking_dropdown' %> +
    <%# todo switch to field[:options].fetch('privacy') %> <% if field[:label].start_with?('Private') %> @@ -46,12 +54,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..e0b028f50 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 @@ -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..6789ebbb7 100644 --- a/app/views/content/form/field_types/_link.html.erb +++ b/app/views/content/form/field_types/_link.html.erb @@ -0,0 +1,175 @@ +<% + 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/_tags.html.erb b/app/views/content/form/field_types/_tags.html.erb index 27b22d33f..760456e7a 100644 --- a/app/views/content/form/field_types/_tags.html.erb +++ b/app/views/content/form/field_types/_tags.html.erb @@ -1,100 +1,152 @@ -
    -
    - <% 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 autosave-closest-form-on-change', + 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..637133ba1 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: 'autosave-closest-form-on-change 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..0033688d4 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 %>