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_circle Linked!';
+ 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 = 'error Failed';
+ 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}
+
+ close
+
+
+ `);
+
+ $('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}
+
+ close
+
+
+ `;
+
+ 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 = '';
+ 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 = '';
+ 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}
+
+ close
+
+
+ `;
+
+ 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
+ Add Your First Event
+
+ `;
+
+ // 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"),''+t.day+" "}},{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('
u.maxMonth?'disabled="disabled"':"")+">"+u.i18n.months[l]+" ");for(a='
'+d.join("")+" ",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('"+l+" ");r=''+d.join("")+" ";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(''+this.options.i18n.clear+" ").appendTo(this.footer).on("click",this.clear.bind(this));this.options.showClearBtn&&t.css({visibility:""});var e=h('
');h(''+this.options.i18n.cancel+" ").appendTo(e).on("click",this.close.bind(this)),h(''+this.options.i18n.done+" ").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+" ":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} `
- : 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 ? '
warning Document 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.
-
-
- Collection title
-
-
-
- Subtitle (optional)
-
-
-
- Description
-
-
-
-
Header image (optional)
-
-
- 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(
-
-
-
-
-
- language
- Universes
-
-
-
- );
- })}
-
-
-
- );
- 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)}
-
-
-
- Back
-
-
- {this.state.active_step === this.steps().length - 1 ? 'Finish' : 'Next'}
-
-
-
-
-
- ))}
-
- {this.state.active_step === this.steps().length && (
-
- All steps completed - you're finished!
-
- Reset
-
-
- )}
-
- );
- }
-}
-
-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}
+
+ close
+
+
+ `);
+
+ $('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.
+
+
+ Got it!
+
+
+
+ `;
+
+ 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
+
+
+
+
+
+
+
+ <%= link_to basil_path, class: 'inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors' do %>
+ auto_awesome
+ Basil
<% end %>
- chevron_right
- <%= link_to basil_content_index_path(content_type: @content.page_type), class: 'grey-text text-darken-2' do %>
- <%= @content.page_type.pluralize %>
+ chevron_right
+ <%= link_to basil_content_index_path(content_type: @content.page_type), class: 'text-gray-500 hover:text-gray-700 transition-colors' do %>
+ <%= @content.page_type.pluralize %>
+ <%= @content.page_type.pluralize.truncate(10) %>
<% end %>
-
-
- <%= 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 Page
+ 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
+
+ Customize importance
+ tune
+
+
+
+
+ <% @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 %>
+
+
+
+
+
+ <%= field.label %>
+
+
+ <%= value.presence || "—" %>
+
+
+
+
+
+ Low
+ <%= 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: 'w-16 sm:w-20 basil-importance-range',
+ data: { field_id: field.id } } %>
+ High
+
+
+
+ <% end %>
+
+
+
+
+
+
+ Adjust importance: Drag sliders to control how much Basil focuses on each field.
+ Set to 0 to ignore a field completely.
+
+
-
- <% 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) %>
-
-
- <%= check_box_tag "basil_commission[include_field][#{field.id}]", field.id, field_previously_used, class: 'js-toggle-additional-field' %>
- <%= field.label %>
-
- <%= 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| %>
+
+
+
+
+ <%= case style
+ when 'photograph' then 'photo_camera'
+ when 'watercolor_painting' then 'brush'
+ when 'pencil_sketch' then 'edit'
+ when 'smiling' then 'sentiment_satisfied'
+ when 'villain' then 'sentiment_very_dissatisfied'
+ when 'horror' then 'nights_stay'
+ when 'painting' then 'palette'
+ when 'sketch' then 'draw'
+ when 'interior' then 'home'
+ when 'exterior' then 'domain'
+ when 'aerial_photograph' then 'flight'
+ when 'fantasy' then 'auto_awesome'
+ when 'anime' then 'star'
+ when 'product_photography' then 'inventory_2'
+ when 'macro_photography' then 'zoom_in'
+ when 'cutaway_render' then 'layers'
+ when 'action_shot' then 'sports'
+ when 'amateur_photograph' then 'photo'
+ when 'celestial_body' then 'brightness_3'
+ when 'abstract' then 'category'
+ when 'geometric' then 'square'
+ when 'symbolic' then 'psychology'
+ else 'auto_awesome'
+ end %>
+
+
+
+ <%= style.humanize %>
+
+
+
+ <% end %>
+
+
+
+ <% if BasilService.experimental_styles_for(@content.page_type).any? %>
+
+
+ science
+ Experimental Styles
+
+
+ <% BasilService.experimental_styles_for(@content.page_type).each do |style| %>
+
+
+ science
+
+ <%= style.humanize %>
+
+
+
+ <% 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| %>
-
-
- <%= f.radio_button :score_adjustment, '-2', { class: 'autosave-closest-form-on-change' } %>
- sentiment_very_dissatisfied
-
-
-
-
- <%= f.radio_button :score_adjustment, '-1', { class: 'autosave-closest-form-on-change' } %>
- sentiment_dissatisfied
-
-
-
-
- <%= f.radio_button :score_adjustment, '1', { class: 'autosave-closest-form-on-change' } %>
- sentiment_satisfied
-
+
+
+
+ <% @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
-
-
- <%= f.radio_button :score_adjustment, '2', { class: 'autosave-closest-form-on-change' } %>
- sentiment_very_satisfied
-
+ <% end %>
+
+
+
+
+
+
<%= @content.name %>
+
+ <%= commission.style.try(:humanize) %> •
+ <%= time_ago_in_words commission.completed_at %> ago
+
-
-
- <%= f.radio_button :score_adjustment, '3', { class: 'autosave-closest-form-on-change' } %>
- favorite
-
+
+
+
+ <%= 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| %>
+
+ <%= f.radio_button :score_adjustment, score,
+ class: 'hidden peer feedback-radio',
+ onchange: "this.form.requestSubmit()" %>
+
+ <%= icon %>
+
+
+ <% end %>
<% end %>
+
+
+
+ <% if commission.saved_at? %>
+
+ check_circle
+ Saved
+
+ <% else %>
+
+ save
+ Save to page
+ Save
+
+ <% end %>
+
+
+ delete
+ Delete
+
+
-
-
- <% 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}?"
+ )
+ %>
+
+
+
+
+
+ <%= link_to @content.name, @content.view_path %>
+ ›
+ <%= field.label %>
+
+
+
+
+
+
+ <%= 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 %>
+
+
+
+
+ <%= 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 @@
+
+
+
+
+ <%= link_to root_path, class: 'text-gray-400 hover:text-gray-500' do %>
+ dashboard
+ Home
+ <% end %>
+
+
+
+
+
+
+
+
+ <%= link_to content.class_name.pluralize, send("#{content.class_name.downcase.pluralize}_path"), class: 'ml-4 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-white' %>
+
+
+
+
+
+
+
+
+ <%= link_to content.name, content.raw_model, class: 'ml-4 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-white truncate max-w-[120px] sm:max-w-none', aria: { current: (current_page == :show ? 'page' : nil) }, data: { breadcrumb_name: true } %>
+
+
+
+
+ <% if current_page == :edit %>
+
+
+
+ <% end %>
+
+
+ <% if current_page == :changelog %>
+
+
+
+ <% end %>
+
+
+
+
+
+ <%= content.name %>
+
+
+
+
+ <% if show_edit_controls && @content.updatable_by?(current_user) %>
+
+
+
+ <% if current_page == :edit %>
+
+ <%= link_to @content do %>
+
+ visibility
+ Done Editing
+
+ <% end %>
+ <% else %>
+
+ <%= link_to polymorphic_path(@content, action: :edit) do %>
+
+ edit
+ Edit Page
+
+ <% end %>
+ <% end %>
+
+
+
+
+ <% end %>
+
+
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 %>
-
+
+
+
+ Template Editor
+
+
+ Configuration
+
+
-
- <%= 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| %>
-
-