Ver Fonte

feat: enhance web UI with pagination, content modal, and fix install script

- Fix install script TypeScript compilation issue
  - Build application in source directory before installation
  - Copy pre-built files instead of building in target directory
  - Install only production dependencies in final installation

- Enhance content page with pagination and full content view
  - Add pagination controls with previous/next buttons
  - Implement full-content modal for viewing complete scraped content
  - Add copy functionality and external link opening in modal
  - Reduce page size from 50 to 20 items for better performance
  - Add offset-based API pagination with proper server-side support

- Fix schedule timing display issue
  - Add formatScheduleTime() function for future/past time handling
  - Calculate proper next run times for disabled schedules based on frequency
  - Fix "Would run: just now" issue for disabled schedules

- Style form input elements
  - Add proper CSS classes for form-input, form-select, form-textarea
  - Include custom dropdown arrow and focus states for better UX

- Add missing UI components
  - Add arrow-left and arrow-right icons
  - Extend ShopResultsResponse interface with total_count for pagination
  - Add character count display and metadata in content items

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh há 8 meses atrás
pai
commit
38eed36ad8

+ 30 - 11
scripts/install.sh

@@ -55,12 +55,9 @@ echo ""
 echo "Creating installation directory..."
 echo "Creating installation directory..."
 mkdir -p "$INSTALL_DIR"
 mkdir -p "$INSTALL_DIR"
 
 
-# Copy application files
-echo "Copying application files..."
-cp -r package.json tsconfig.json src "$INSTALL_DIR/"
-
-# Change to installation directory
-cd "$INSTALL_DIR"
+# Get the script directory (where install.sh is located)
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
 
 
 # Check if Node.js is installed
 # Check if Node.js is installed
 if ! command -v node &> /dev/null; then
 if ! command -v node &> /dev/null; then
@@ -74,14 +71,36 @@ if ! command -v npm &> /dev/null; then
     exit 1
     exit 1
 fi
 fi
 
 
-# Install dependencies
-echo "Installing dependencies..."
-npm install --production
-
-# Build TypeScript
+# Build application in the source directory first
 echo "Building application..."
 echo "Building application..."
+cd "$PROJECT_DIR"
+
+# Install all dependencies (including dev dependencies for build)
+echo "Installing build dependencies..."
+npm install
+
+# Build the application
+echo "Compiling TypeScript and building web assets..."
 npm run build
 npm run build
 
 
+# Check if build was successful
+if [ ! -f "$PROJECT_DIR/dist/index.js" ]; then
+    echo "Error: Build failed - dist/index.js not found"
+    exit 1
+fi
+
+# Copy application files
+echo "Copying application files..."
+cp -r package.json "$INSTALL_DIR/"
+cp -r dist "$INSTALL_DIR/"
+
+# Change to installation directory
+cd "$INSTALL_DIR"
+
+# Install only production dependencies
+echo "Installing production dependencies..."
+npm install --production
+
 # Create environment file
 # Create environment file
 echo "Creating environment file..."
 echo "Creating environment file..."
 cat > "$INSTALL_DIR/.env" <<EOF
 cat > "$INSTALL_DIR/.env" <<EOF

+ 275 - 17
web/src/components/pages/ContentPage.ts

@@ -10,7 +10,10 @@ export class ContentPage {
   private shops: ShopWithAnalytics[] = [];
   private shops: ShopWithAnalytics[] = [];
   private selectedShopId: string | null = null;
   private selectedShopId: string | null = null;
   private contentData: ShopResultsResponse | null = null;
   private contentData: ShopResultsResponse | null = null;
-  private filters: ContentFilters = { limit: 50 };
+  private filters: ContentFilters = { limit: 20 }; // Reduced default limit
+  private currentPage: number = 1;
+  private totalPages: number = 1;
+  private totalItems: number = 0;
   private eventsbound: boolean = false;
   private eventsbound: boolean = false;
 
 
   constructor(
   constructor(
@@ -109,6 +112,82 @@ export class ContentPage {
               <span class="ml-3 text-gray-600 dark:text-gray-400">Loading content...</span>
               <span class="ml-3 text-gray-600 dark:text-gray-400">Loading content...</span>
             </div>
             </div>
           </div>
           </div>
+          <!-- Pagination -->
+          <div id="pagination-container" style="display: none;">
+            <div class="flex items-center justify-between mt-6 pt-6 border-t border-gray-200 dark:border-gray-600">
+              <div class="flex items-center space-x-2">
+                <span class="text-sm text-gray-700 dark:text-gray-300" id="pagination-info">
+                  Showing 1-20 of 150 items
+                </span>
+              </div>
+              <div class="flex items-center space-x-2" id="pagination-controls">
+                <button class="btn btn-secondary btn-sm" id="prev-page" disabled>
+                  <div class="w-4 h-4 mr-1">${getIcon('arrow-left')}</div>
+                  Previous
+                </button>
+                <span class="px-3 py-1 text-sm" id="page-numbers">
+                  Page 1 of 8
+                </span>
+                <button class="btn btn-secondary btn-sm" id="next-page">
+                  Next
+                  <div class="w-4 h-4 ml-1">${getIcon('arrow-right')}</div>
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- Full Content Modal -->
+      <div class="fixed inset-0 z-50 hidden" id="content-modal">
+        <div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
+          <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" id="modal-overlay"></div>
+          <div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
+            <div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6">
+              <div class="sm:flex sm:items-start">
+                <div class="w-full">
+                  <div class="flex justify-between items-start mb-4">
+                    <div class="flex-1">
+                      <h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
+                        Content Details
+                      </h3>
+                      <p class="mt-1 text-sm text-gray-500 dark:text-gray-400" id="modal-url">
+                        URL will appear here
+                      </p>
+                    </div>
+                    <button type="button" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" id="close-modal">
+                      <span class="sr-only">Close</span>
+                      <div class="w-6 h-6">${getIcon('x')}</div>
+                    </button>
+                  </div>
+                  <div class="flex justify-between items-center mb-4">
+                    <div class="flex space-x-2">
+                      <span class="badge" id="modal-type-badge">Content Type</span>
+                      <span class="badge" id="modal-change-badge">Status</span>
+                    </div>
+                    <div class="flex space-x-2">
+                      <button type="button" class="btn btn-secondary btn-sm" id="copy-content-btn">
+                        <div class="w-4 h-4 mr-1">${getIcon('copy')}</div>
+                        Copy
+                      </button>
+                      <button type="button" class="btn btn-secondary btn-sm" id="open-url-btn">
+                        <div class="w-4 h-4 mr-1">${getIcon('external-link')}</div>
+                        Open URL
+                      </button>
+                    </div>
+                  </div>
+                  <div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 max-h-96 overflow-y-auto">
+                    <pre class="whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-200" id="modal-content">
+                      Content will appear here...
+                    </pre>
+                  </div>
+                  <div class="mt-4 text-xs text-gray-500 dark:text-gray-400" id="modal-metadata">
+                    Last updated: --
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
         </div>
         </div>
       </div>
       </div>
     `;
     `;
@@ -129,6 +208,62 @@ export class ContentPage {
       e.preventDefault();
       e.preventDefault();
       this.applyFilters();
       this.applyFilters();
     });
     });
+
+    // Pagination controls
+    const prevBtn = this.element.querySelector('#prev-page');
+    const nextBtn = this.element.querySelector('#next-page');
+
+    prevBtn?.addEventListener('click', () => {
+      if (this.currentPage > 1) {
+        this.currentPage--;
+        this.loadContent();
+      }
+    });
+
+    nextBtn?.addEventListener('click', () => {
+      if (this.currentPage < this.totalPages) {
+        this.currentPage++;
+        this.loadContent();
+      }
+    });
+
+    // Modal controls
+    const modal = this.element.querySelector('#content-modal');
+    const modalOverlay = this.element.querySelector('#modal-overlay');
+    const closeModalBtn = this.element.querySelector('#close-modal');
+    const copyContentBtn = this.element.querySelector('#copy-content-btn');
+    const openUrlBtn = this.element.querySelector('#open-url-btn');
+
+    const closeModal = () => {
+      modal?.classList.add('hidden');
+    };
+
+    closeModalBtn?.addEventListener('click', closeModal);
+    modalOverlay?.addEventListener('click', closeModal);
+
+    copyContentBtn?.addEventListener('click', async () => {
+      if (this.currentModalItem) {
+        try {
+          await navigator.clipboard.writeText(this.currentModalItem.content);
+          this.toastService.success('Copied!', 'Content copied to clipboard');
+        } catch (error) {
+          this.toastService.error('Copy Failed', 'Could not copy to clipboard');
+        }
+      }
+    });
+
+    openUrlBtn?.addEventListener('click', () => {
+      if (this.currentModalItem) {
+        window.open(this.currentModalItem.url, '_blank');
+      }
+    });
+
+    // ESC key to close modal
+    document.addEventListener('keydown', (e) => {
+      if (e.key === 'Escape') {
+        closeModal();
+      }
+    });
   }
   }
 
 
   private async loadShops(): Promise<void> {
   private async loadShops(): Promise<void> {
@@ -232,7 +367,7 @@ export class ContentPage {
     const formData = new FormData(form);
     const formData = new FormData(form);
 
 
     this.filters = {
     this.filters = {
-      limit: 50, // Keep reasonable limit
+      limit: 20, // Reduced limit for pagination
       content_type: (formData.get('content-type') as 'shipping' | 'contacts' | 'terms' | 'faq') || undefined,
       content_type: (formData.get('content-type') as 'shipping' | 'contacts' | 'terms' | 'faq') || undefined,
       date_from: (formData.get('date-from') as string) || undefined,
       date_from: (formData.get('date-from') as string) || undefined,
       date_to: (formData.get('date-to') as string) || undefined,
       date_to: (formData.get('date-to') as string) || undefined,
@@ -245,6 +380,8 @@ export class ContentPage {
       }
       }
     });
     });
 
 
+    // Reset to first page when filters change
+    this.currentPage = 1;
     this.loadContent();
     this.loadContent();
   }
   }
 
 
@@ -262,8 +399,14 @@ export class ContentPage {
       </div>
       </div>
     `;
     `;
 
 
+    // Add pagination offset to filters
+    const paginatedFilters = {
+      ...this.filters,
+      offset: (this.currentPage - 1) * (this.filters.limit || 20)
+    };
+
     try {
     try {
-      const response = await this.apiService.getShopResults(this.selectedShopId, this.filters);
+      const response = await this.apiService.getShopResults(this.selectedShopId, paginatedFilters);
 
 
       if (response.success && response.data) {
       if (response.success && response.data) {
         this.contentData = response.data;
         this.contentData = response.data;
@@ -290,30 +433,42 @@ export class ContentPage {
 
 
     const container = this.element?.querySelector('#content-results');
     const container = this.element?.querySelector('#content-results');
     const summary = this.element?.querySelector('#results-summary');
     const summary = this.element?.querySelector('#results-summary');
+    const paginationContainer = this.element?.querySelector('#pagination-container');
 
 
     if (!container || !summary) return;
     if (!container || !summary) return;
 
 
-    const { results, filters } = this.contentData;
+    const { results, total_count = 0 } = this.contentData;
+
+    // Calculate pagination
+    this.totalItems = total_count;
+    this.totalPages = Math.ceil(this.totalItems / (this.filters.limit || 20));
 
 
-    // Count total items
-    const totalItems = Object.values(results).reduce((sum, items) => sum + items.length, 0);
+    // Count current page items
+    const currentPageItems = Object.values(results).reduce((sum, items) => sum + items.length, 0);
 
 
     // Update summary
     // Update summary
     const selectedShop = this.shops.find(s => s.id === this.selectedShopId);
     const selectedShop = this.shops.find(s => s.id === this.selectedShopId);
     const shopName = selectedShop ? getDomainFromUrl(selectedShop.url) : 'Unknown Shop';
     const shopName = selectedShop ? getDomainFromUrl(selectedShop.url) : 'Unknown Shop';
 
 
-    summary.textContent = `Showing ${totalItems} content items from ${shopName}`;
+    const startItem = (this.currentPage - 1) * (this.filters.limit || 20) + 1;
+    const endItem = Math.min(startItem + currentPageItems - 1, this.totalItems);
 
 
-    if (totalItems === 0) {
+    summary.textContent = `Showing ${startItem}-${endItem} of ${this.totalItems} content items from ${shopName}`;
+
+    // Update pagination
+    this.updatePagination();
+
+    if (currentPageItems === 0) {
       container.innerHTML = `
       container.innerHTML = `
         <div class="text-center py-12">
         <div class="text-center py-12">
           <div class="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center mx-auto mb-4">
           <div class="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center mx-auto mb-4">
             <div class="w-8 h-8 text-gray-400">${getIcon('search')}</div>
             <div class="w-8 h-8 text-gray-400">${getIcon('search')}</div>
           </div>
           </div>
           <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No content found</h3>
           <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No content found</h3>
-          <p class="text-gray-500 dark:text-gray-400">Try adjusting your filters</p>
+          <p class="text-gray-500 dark:text-gray-400">Try adjusting your filters or go to previous page</p>
         </div>
         </div>
       `;
       `;
+      if (paginationContainer) (paginationContainer as HTMLElement).style.display = 'none';
       return;
       return;
     }
     }
 
 
@@ -339,12 +494,51 @@ export class ContentPage {
         }).filter(Boolean).join('')}
         }).filter(Boolean).join('')}
       </div>
       </div>
     `;
     `;
+
+    // Show pagination if we have multiple pages
+    if (paginationContainer) {
+      (paginationContainer as HTMLElement).style.display = this.totalPages > 1 ? 'block' : 'none';
+    }
+
+    // Bind view full content events
+    this.bindContentEvents();
+  }
+
+  private updatePagination(): void {
+    const paginationInfo = this.element?.querySelector('#pagination-info');
+    const pageNumbers = this.element?.querySelector('#page-numbers');
+    const prevBtn = this.element?.querySelector('#prev-page') as HTMLButtonElement;
+    const nextBtn = this.element?.querySelector('#next-page') as HTMLButtonElement;
+
+    if (paginationInfo && pageNumbers && prevBtn && nextBtn) {
+      const startItem = (this.currentPage - 1) * (this.filters.limit || 20) + 1;
+      const endItem = Math.min(this.currentPage * (this.filters.limit || 20), this.totalItems);
+
+      paginationInfo.textContent = `Showing ${startItem}-${endItem} of ${this.totalItems} items`;
+      pageNumbers.textContent = `Page ${this.currentPage} of ${this.totalPages}`;
+
+      prevBtn.disabled = this.currentPage <= 1;
+      nextBtn.disabled = this.currentPage >= this.totalPages;
+    }
+  }
+
+  private bindContentEvents(): void {
+    // Bind "View Full" button events
+    this.element?.querySelectorAll('.view-full-btn').forEach(btn => {
+      btn.addEventListener('click', (e) => {
+        const contentId = (e.target as HTMLElement).closest('.view-full-btn')?.getAttribute('data-content-id');
+        if (contentId) {
+          this.showFullContent(contentId);
+        }
+      });
+    });
   }
   }
 
 
   private renderContentItem(item: ShopContent): string {
   private renderContentItem(item: ShopContent): string {
     const domain = getDomainFromUrl(item.url);
     const domain = getDomainFromUrl(item.url);
     const hasChanged = item.changed;
     const hasChanged = item.changed;
-    const preview = item.content.substring(0, 200) + (item.content.length > 200 ? '...' : '');
+    const preview = item.content.substring(0, 300) + (item.content.length > 300 ? '...' : '');
+    const isLongContent = item.content.length > 300;
 
 
     return `
     return `
       <div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
       <div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
@@ -369,17 +563,28 @@ export class ContentPage {
             <a href="${item.url}" target="_blank" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mb-2 block">
             <a href="${item.url}" target="_blank" class="text-sm text-primary-600 dark:text-primary-400 hover:underline mb-2 block">
               ${item.url}
               ${item.url}
             </a>
             </a>
-            <p class="text-sm text-gray-500 dark:text-gray-400 mb-3">
-              ${preview.replace(/[#*]/g, '')}
-            </p>
+            <div class="text-sm text-gray-500 dark:text-gray-400 mb-3">
+              <div class="whitespace-pre-wrap">${preview.replace(/[#*]/g, '')}</div>
+              ${isLongContent ? `
+                <button class="view-full-btn text-primary-600 dark:text-primary-400 hover:underline text-xs mt-1" data-content-id="${item.id}">
+                  View full content
+                </button>
+              ` : ''}
+            </div>
             <div class="text-xs text-gray-400 dark:text-gray-500">
             <div class="text-xs text-gray-400 dark:text-gray-500">
-              Last updated: ${formatRelativeTime(item.updated_at)}
+              Last updated: ${formatRelativeTime(item.updated_at)} • ${item.content.length} characters
             </div>
             </div>
           </div>
           </div>
-          <div class="ml-4 flex-shrink-0">
+          <div class="ml-4 flex-shrink-0 space-y-2">
+            ${isLongContent ? `
+              <button class="view-full-btn btn btn-secondary btn-sm w-full" data-content-id="${item.id}">
+                <div class="w-4 h-4 mr-1">${getIcon('eye')}</div>
+                View Full
+              </button>
+            ` : ''}
             <button
             <button
-              class="btn btn-secondary btn-sm"
-              onclick="navigator.clipboard.writeText(\`${item.content.replace(/`/g, '\\`')}\`).then(() => {
+              class="btn btn-secondary btn-sm w-full"
+              onclick="navigator.clipboard.writeText(\`${item.content.replace(/`/g, '\\`').replace(/\$/g, '\\$')}\`).then(() => {
                 document.dispatchEvent(new CustomEvent('show-toast', {
                 document.dispatchEvent(new CustomEvent('show-toast', {
                   detail: { type: 'success', title: 'Copied!', message: 'Content copied to clipboard' }
                   detail: { type: 'success', title: 'Copied!', message: 'Content copied to clipboard' }
                 }));
                 }));
@@ -394,6 +599,59 @@ export class ContentPage {
     `;
     `;
   }
   }
 
 
+  private showFullContent(contentId: string): void {
+    // Find the content item by ID
+    if (!this.contentData) return;
+
+    let foundItem: ShopContent | null = null;
+    for (const [_, items] of Object.entries(this.contentData.results)) {
+      foundItem = items.find(item => item.id === contentId) || null;
+      if (foundItem) break;
+    }
+
+    if (!foundItem) return;
+
+    // Populate modal
+    const modal = this.element?.querySelector('#content-modal');
+    const modalTitle = this.element?.querySelector('#modal-title');
+    const modalUrl = this.element?.querySelector('#modal-url');
+    const modalContent = this.element?.querySelector('#modal-content');
+    const modalMetadata = this.element?.querySelector('#modal-metadata');
+    const modalTypeBadge = this.element?.querySelector('#modal-type-badge');
+    const modalChangeBadge = this.element?.querySelector('#modal-change-badge');
+
+    if (modalTitle) modalTitle.textContent = `${getContentTypeLabel(foundItem.content_type)} Content`;
+    if (modalUrl) modalUrl.textContent = foundItem.url;
+    if (modalContent) modalContent.textContent = foundItem.content;
+    if (modalMetadata) {
+      modalMetadata.textContent = `Last updated: ${formatRelativeTime(foundItem.updated_at)} • ${foundItem.content.length} characters • Hash: ${foundItem.content_hash.substring(0, 8)}`;
+    }
+
+    // Update badges
+    if (modalTypeBadge) {
+      modalTypeBadge.textContent = getContentTypeLabel(foundItem.content_type);
+      modalTypeBadge.className = 'badge badge-info';
+    }
+
+    if (modalChangeBadge) {
+      if (foundItem.changed) {
+        modalChangeBadge.textContent = 'Changed';
+        modalChangeBadge.className = 'badge badge-warning';
+      } else {
+        modalChangeBadge.textContent = 'Unchanged';
+        modalChangeBadge.className = 'badge badge-success';
+      }
+    }
+
+    // Store current item for modal actions
+    this.currentModalItem = foundItem;
+
+    // Show modal
+    modal?.classList.remove('hidden');
+  }
+
+  private currentModalItem: ShopContent | null = null;
+
   destroy(): void {
   destroy(): void {
     this.eventsbound = false;
     this.eventsbound = false;
   }
   }

+ 38 - 5
web/src/components/pages/SchedulesPage.ts

@@ -3,7 +3,7 @@ import { ToastService } from '../../services/ToastService';
 import { Router } from '../../app/Router';
 import { Router } from '../../app/Router';
 import { ShopWithAnalytics, ScheduledJob } from '../../types';
 import { ShopWithAnalytics, ScheduledJob } from '../../types';
 import { getIcon } from '../../utils/icons';
 import { getIcon } from '../../utils/icons';
-import { formatRelativeTime, getDomainFromUrl } from '../../utils/helpers';
+import { formatRelativeTime, formatScheduleTime, getDomainFromUrl } from '../../utils/helpers';
 
 
 export class SchedulesPage {
 export class SchedulesPage {
   private element: HTMLElement | null = null;
   private element: HTMLElement | null = null;
@@ -203,7 +203,7 @@ export class SchedulesPage {
     const activeSchedule = shopSchedules.find(s => s.status === 'queued' && s.enabled);
     const activeSchedule = shopSchedules.find(s => s.status === 'queued' && s.enabled);
     const hasEnabledSchedule = !!activeSchedule;
     const hasEnabledSchedule = !!activeSchedule;
 
 
-    // Find next scheduled run
+    // Find next scheduled run - for enabled, use active; for disabled, find most recent queued
     const nextRun = hasEnabledSchedule ? activeSchedule : shopSchedules.find(s => s.status === 'queued');
     const nextRun = hasEnabledSchedule ? activeSchedule : shopSchedules.find(s => s.status === 'queued');
 
 
     return `
     return `
@@ -243,7 +243,7 @@ export class SchedulesPage {
                 </div>
                 </div>
                 ${nextRun ? `
                 ${nextRun ? `
                   <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
                   <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
-                    Next: ${formatRelativeTime(nextRun.next_run_at)}
+                    Next: ${this.calculateNextScheduleTime(nextRun)}
                   </p>
                   </p>
                   <p class="text-xs text-gray-400 dark:text-gray-500">
                   <p class="text-xs text-gray-400 dark:text-gray-500">
                     Frequency: ${nextRun.frequency || 'weekly'}
                     Frequency: ${nextRun.frequency || 'weekly'}
@@ -256,7 +256,10 @@ export class SchedulesPage {
                 </div>
                 </div>
                 ${nextRun ? `
                 ${nextRun ? `
                   <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
                   <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
-                    Would run: ${formatRelativeTime(nextRun.next_run_at)}
+                    Would run: ${this.calculateNextScheduleTime(nextRun)}
+                  </p>
+                  <p class="text-xs text-gray-400 dark:text-gray-500">
+                    Frequency: ${nextRun.frequency || 'weekly'}
                   </p>
                   </p>
                 ` : `
                 ` : `
                   <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
                   <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
@@ -289,7 +292,7 @@ export class SchedulesPage {
               ${shopSchedules.slice(0, 3).map(schedule => `
               ${shopSchedules.slice(0, 3).map(schedule => `
                 <div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
                 <div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded">
                   <span class="text-gray-700 dark:text-gray-300">
                   <span class="text-gray-700 dark:text-gray-300">
-                    ${formatRelativeTime(schedule.next_run_at)}
+                    ${this.calculateNextScheduleTime(schedule)}
                   </span>
                   </span>
                   <span class="badge ${this.getScheduleStatusClass(schedule.status)} text-xs">
                   <span class="badge ${this.getScheduleStatusClass(schedule.status)} text-xs">
                     ${schedule.status}
                     ${schedule.status}
@@ -318,6 +321,36 @@ export class SchedulesPage {
     }
     }
   }
   }
 
 
+  private calculateNextScheduleTime(schedule: ScheduledJob): string {
+    // For enabled schedules, use the actual next_run_at
+    if (schedule.enabled) {
+      return formatScheduleTime(schedule.next_run_at);
+    }
+
+    // For disabled schedules, calculate what the next run would be based on frequency
+    const frequency = schedule.frequency || 'weekly';
+    const now = new Date();
+    let nextRun: Date;
+
+    switch (frequency.toLowerCase()) {
+      case 'hourly':
+        nextRun = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
+        break;
+      case 'daily':
+        nextRun = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
+        break;
+      case 'weekly':
+      default:
+        nextRun = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 1 week from now
+        break;
+      case 'monthly':
+        nextRun = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()); // 1 month from now
+        break;
+    }
+
+    return formatScheduleTime(nextRun.toISOString());
+  }
+
   private bindToggleEvents(): void {
   private bindToggleEvents(): void {
     if (!this.element) return;
     if (!this.element) return;
 
 

+ 1 - 0
web/src/services/ApiService.ts

@@ -116,6 +116,7 @@ export class ApiService {
     const params = new URLSearchParams();
     const params = new URLSearchParams();
 
 
     if (filters.limit) params.append('limit', filters.limit.toString());
     if (filters.limit) params.append('limit', filters.limit.toString());
+    if (filters.offset) params.append('offset', filters.offset.toString());
     if (filters.date_from) params.append('date_from', filters.date_from);
     if (filters.date_from) params.append('date_from', filters.date_from);
     if (filters.date_to) params.append('date_to', filters.date_to);
     if (filters.date_to) params.append('date_to', filters.date_to);
     if (filters.content_type) params.append('content_type', filters.content_type);
     if (filters.content_type) params.append('content_type', filters.content_type);

+ 15 - 0
web/src/styles/main.css

@@ -37,6 +37,21 @@
     @apply block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder-gray-500;
     @apply block w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder-gray-500;
   }
   }
 
 
+  .form-input {
+    @apply input;
+  }
+
+  .form-select {
+    @apply input pr-10 bg-no-repeat bg-right;
+    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
+    background-position: right 0.5rem center;
+    background-size: 1.5em 1.5em;
+  }
+
+  .form-textarea {
+    @apply input min-h-[80px] resize-y;
+  }
+
   .card {
   .card {
     @apply bg-white rounded-lg shadow dark:bg-gray-800;
     @apply bg-white rounded-lg shadow dark:bg-gray-800;
   }
   }

+ 1 - 0
web/src/types/index.ts

@@ -172,6 +172,7 @@ export interface ShopResultsResponse {
     terms_of_conditions: ShopContent[];
     terms_of_conditions: ShopContent[];
     faq: ShopContent[];
     faq: ShopContent[];
   };
   };
+  total_count?: number; // Total count for pagination
 }
 }
 
 
 export interface Route {
 export interface Route {

+ 37 - 0
web/src/utils/helpers.ts

@@ -34,6 +34,43 @@ export function formatRelativeTime(dateString: string): string {
   return `${diffInYears}y ago`;
   return `${diffInYears}y ago`;
 }
 }
 
 
+/**
+ * Format time for scheduled events - handles both future and past times appropriately
+ */
+export function formatScheduleTime(dateString: string): string {
+  const date = new Date(dateString);
+  const now = new Date();
+  const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
+
+  // If it's in the future
+  if (diffInSeconds > 0) {
+    if (diffInSeconds < 60) {
+      return 'in a few moments';
+    }
+
+    const diffInMinutes = Math.floor(diffInSeconds / 60);
+    if (diffInMinutes < 60) {
+      return `in ${diffInMinutes}m`;
+    }
+
+    const diffInHours = Math.floor(diffInMinutes / 60);
+    if (diffInHours < 24) {
+      return `in ${diffInHours}h`;
+    }
+
+    const diffInDays = Math.floor(diffInHours / 24);
+    if (diffInDays < 30) {
+      return `in ${diffInDays}d`;
+    }
+
+    // For far future times, show absolute date
+    return formatAbsoluteTime(dateString);
+  }
+
+  // If it's in the past, use existing relative time logic
+  return formatRelativeTime(dateString);
+}
+
 /**
 /**
  * Format absolute time (e.g., "Dec 15, 2023 at 2:30 PM")
  * Format absolute time (e.g., "Dec 15, 2023 at 2:30 PM")
  */
  */

+ 4 - 0
web/src/utils/icons.ts

@@ -46,6 +46,10 @@ export const icons = {
 
 
   // Copy
   // Copy
   copy: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>',
   copy: '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>',
+
+  // Arrows
+  'arrow-left': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>',
+  'arrow-right': '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>',
 };
 };
 
 
 export function getIcon(name: keyof typeof icons): string {
 export function getIcon(name: keyof typeof icons): string {