Kaynağa Gözat

Fix WebUI z-index issues and implement form state persistence

This commit addresses multiple critical UI/UX issues in the WebUI:

1. Fixed z-index layering hierarchy (Fixes #1, #2)
   - Model Status Bar: z-40 → z-35 with left-64 offset to avoid sidebar
   - PromptTextarea suggestions: z-50 → z-30 to stay below sidebar
   - Sidebar remains at z-40 for proper navigation
   - Status bar now properly positioned and always visible

2. Resolved sidebar menu click blocking on text2img page (Fixes #2)
   - PromptTextarea autocomplete dropdown was overlapping sidebar
   - Reduced suggestions dropdown z-index from 50 to 30
   - All pages now have clickable sidebar navigation

3. Implemented form state persistence across navigation (Fixes #3)
   - Created useLocalStorage hook in /webui/lib/hooks.ts
   - Applied to text2img, img2img, and upscaler pages
   - Form data now persists in localStorage with unique keys:
     * text2img-form-data
     * img2img-form-data
     * upscaler-form-data
   - Users can navigate away and return without losing settings

4. Updated CLAUDE.md with WebUI architecture documentation
   - Added Web UI Architecture section
   - Documented z-index hierarchy and best practices
   - Explained form state persistence implementation
   - Added common issues and solutions reference

Components Modified:
- /webui/components/model-status-bar.tsx - Fixed z-index and positioning
- /webui/components/prompt-textarea.tsx - Fixed suggestions z-index
- /webui/app/text2img/page.tsx - Added form persistence
- /webui/app/img2img/page.tsx - Added form persistence
- /webui/app/upscaler/page.tsx - Added form persistence
- /webui/lib/hooks.ts - New localStorage hooks
- CLAUDE.md - Added WebUI documentation

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

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 3 ay önce
ebeveyn
işleme
e9525064ec

+ 152 - 0
CLAUDE.md

@@ -363,3 +363,155 @@ The server will automatically use the correct parameters when loading models bas
 4. Provide correct command-line flags for manual loading
 
 See `MODEL_DETECTION.md` for complete documentation on the detection system.
+
+## Web UI Architecture
+
+The project includes a Next.js-based web UI located in `/webui` that provides a modern interface for interacting with the REST API.
+
+### Building the Web UI
+
+```bash
+# Build Web UI manually
+cd webui
+npm install
+npm run build
+
+# Build via CMake (automatically copies to build directory)
+cmake --build build --target webui-build
+```
+
+The built UI is automatically copied to `build/webui/` and served by the REST API server at `/ui/`.
+
+### WebUI Structure
+
+- **Framework**: Next.js 16 with React, TypeScript, and Tailwind CSS
+- **Routing**: App router with static page generation
+- **UI Components**: Shadcn/ui components in `/webui/components/ui/`
+- **Pages**:
+  - `/webui/app/text2img/page.tsx` - Text-to-image generation
+  - `/webui/app/img2img/page.tsx` - Image-to-image generation
+  - `/webui/app/upscaler/page.tsx` - Image upscaling
+  - `/webui/app/models/page.tsx` - Model management
+  - `/webui/app/queue/page.tsx` - Queue status
+
+### Important Components
+
+1. **Main Layout** (`/webui/components/main-layout.tsx`)
+   - Provides consistent layout with sidebar and status bar
+   - Includes `Sidebar` and `ModelStatusBar` components
+
+2. **Sidebar** (`/webui/components/sidebar.tsx`)
+   - Fixed position navigation (z-index: 40)
+   - Always visible on the left side
+   - Handles page navigation
+
+3. **Model Status Bar** (`/webui/components/model-status-bar.tsx`)
+   - Fixed position at bottom (z-index: 35)
+   - Positioned with `left-64` to avoid overlapping sidebar
+   - Shows current model status, queue status, and generation progress
+   - Polls server every 1-5 seconds for updates
+
+4. **Prompt Textarea** (`/webui/components/prompt-textarea.tsx`)
+   - Advanced textarea with syntax highlighting
+   - Autocomplete for LoRAs and embeddings
+   - Suggestions dropdown (z-index: 30 - below sidebar)
+   - Highlights LoRA tags (`<lora:name:weight>`) and embedding names
+
+### Z-Index Layering
+
+The WebUI uses a specific z-index hierarchy to ensure proper stacking:
+
+```
+Sidebar:                z-40  (always on top for navigation)
+Model Status Bar:       z-35  (visible but doesn't block sidebar)
+Autocomplete Dropdowns: z-30  (below sidebar to allow navigation)
+Main Content:           z-0   (default)
+```
+
+**Important**: When adding new floating/fixed elements, respect this hierarchy to avoid blocking the sidebar navigation.
+
+### Form State Persistence
+
+All generation pages (text2img, img2img, upscaler) use localStorage to persist form state across navigation.
+
+**Implementation** (`/webui/lib/hooks.ts`):
+
+```typescript
+// Custom hook for localStorage persistence
+const [formData, setFormData] = useLocalStorage('page-form-data', defaultValues);
+```
+
+**Keys used**:
+- `text2img-form-data` - Text-to-image form state
+- `img2img-form-data` - Image-to-image form state
+- `upscaler-form-data` - Upscaler form state
+
+**Behavior**:
+- Form state is automatically saved to localStorage on every change
+- State is restored when returning to the page
+- Users can navigate away and return without losing their settings
+- Large base64 images (img2img, upscaler) are also persisted but may hit localStorage size limits (~5-10MB)
+
+### API Client
+
+The WebUI communicates with the REST API via `/webui/lib/api.ts`:
+
+```typescript
+import { apiClient } from '@/lib/api';
+
+// Examples
+const models = await apiClient.getModels('checkpoint');
+const job = await apiClient.text2img(formData);
+const status = await apiClient.getJobStatus(jobId);
+```
+
+**Base URL Configuration**: The API base URL is configured in `/webui/.env.local` and defaults to the server's configured endpoint.
+
+### Development Workflow for WebUI
+
+1. **Making UI Changes**:
+   ```bash
+   cd webui
+   npm run dev  # Start development server on port 3000
+   ```
+
+2. **Building for Production**:
+   ```bash
+   # Via CMake (recommended)
+   cmake --build build --target webui-build
+
+   # Or manually
+   cd webui && npm run build
+   ```
+
+3. **Testing**:
+   - Development: Changes are hot-reloaded at `http://localhost:3000`
+   - Production: Build and access via REST server at `http://localhost:8080/ui/`
+
+### Common UI Issues and Solutions
+
+**Issue**: Sidebar menu items not clickable
+- **Cause**: Element with higher z-index overlapping sidebar
+- **Solution**: Ensure all floating elements have z-index < 40
+
+**Issue**: Form state lost on navigation
+- **Cause**: Not using `useLocalStorage` hook
+- **Solution**: Replace `useState` with `useLocalStorage` for form data
+
+**Issue**: Status bar not visible
+- **Cause**: Z-index too low or hidden behind content
+- **Solution**: Use z-index 35 and adjust `left` offset to avoid sidebar
+
+### WebUI Configuration
+
+The server dynamically generates `/ui/config.js` with runtime configuration:
+
+```javascript
+window.SD_REST_CONFIG = {
+  apiBaseUrl: 'http://localhost:8080',
+  version: '1.0.0',
+  features: { /* enabled features */ }
+};
+```
+
+This allows the WebUI to adapt to different server configurations without rebuilding.

+ 27 - 10
webui/app/img2img/page.tsx

@@ -12,18 +12,35 @@ import { Card, CardContent } from '@/components/ui/card';
 import { apiClient, type JobInfo } from '@/lib/api';
 import { Loader2, Download, X, Upload } from 'lucide-react';
 import { downloadImage, fileToBase64 } from '@/lib/utils';
+import { useLocalStorage } from '@/lib/hooks';
+
+type Img2ImgFormData = {
+  prompt: string;
+  negative_prompt: string;
+  image: string;
+  strength: number;
+  steps: number;
+  cfg_scale: number;
+  seed: string;
+  sampling_method: string;
+};
+
+const defaultFormData: Img2ImgFormData = {
+  prompt: '',
+  negative_prompt: '',
+  image: '',
+  strength: 0.75,
+  steps: 20,
+  cfg_scale: 7.5,
+  seed: '',
+  sampling_method: 'euler_a',
+};
 
 export default function Img2ImgPage() {
-  const [formData, setFormData] = useState({
-    prompt: '',
-    negative_prompt: '',
-    image: '',
-    strength: 0.75,
-    steps: 20,
-    cfg_scale: 7.5,
-    seed: '',
-    sampling_method: 'euler_a',
-  });
+  const [formData, setFormData] = useLocalStorage<Img2ImgFormData>(
+    'img2img-form-data',
+    defaultFormData
+  );
 
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);

+ 18 - 12
webui/app/text2img/page.tsx

@@ -12,20 +12,26 @@ import { Card, CardContent } from '@/components/ui/card';
 import { apiClient, type GenerationRequest, type JobInfo, type ModelInfo } from '@/lib/api';
 import { Loader2, Download, X } from 'lucide-react';
 import { downloadImage } from '@/lib/utils';
+import { useLocalStorage } from '@/lib/hooks';
+
+const defaultFormData: GenerationRequest = {
+  prompt: '',
+  negative_prompt: '',
+  width: 512,
+  height: 512,
+  steps: 20,
+  cfg_scale: 7.5,
+  seed: '',
+  sampling_method: 'euler_a',
+  scheduler: 'default',
+  batch_count: 1,
+};
 
 export default function Text2ImgPage() {
-  const [formData, setFormData] = useState<GenerationRequest>({
-    prompt: '',
-    negative_prompt: '',
-    width: 512,
-    height: 512,
-    steps: 20,
-    cfg_scale: 7.5,
-    seed: '',
-    sampling_method: 'euler_a',
-    scheduler: 'default',
-    batch_count: 1,
-  });
+  const [formData, setFormData] = useLocalStorage<GenerationRequest>(
+    'text2img-form-data',
+    defaultFormData
+  );
 
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);

+ 17 - 5
webui/app/upscaler/page.tsx

@@ -10,13 +10,25 @@ import { Card, CardContent } from '@/components/ui/card';
 import { apiClient, type JobInfo, type ModelInfo } from '@/lib/api';
 import { Loader2, Download, X, Upload } from 'lucide-react';
 import { downloadImage, fileToBase64 } from '@/lib/utils';
+import { useLocalStorage } from '@/lib/hooks';
+
+type UpscalerFormData = {
+  image: string;
+  upscale_factor: number;
+  model: string;
+};
+
+const defaultFormData: UpscalerFormData = {
+  image: '',
+  upscale_factor: 2,
+  model: '',
+};
 
 export default function UpscalerPage() {
-  const [formData, setFormData] = useState({
-    image: '',
-    upscale_factor: 2,
-    model: '',
-  });
+  const [formData, setFormData] = useLocalStorage<UpscalerFormData>(
+    'upscaler-form-data',
+    defaultFormData
+  );
 
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);

+ 2 - 1
webui/components/model-status-bar.tsx

@@ -124,11 +124,12 @@ export function ModelStatusBar() {
   return (
     <div
       className={cn(
-        'fixed bottom-0 left-0 right-0 border-t-2 px-4 py-3 shadow-lg z-50',
+        'fixed bottom-0 left-64 right-0 border-t-2 px-4 py-3 shadow-lg z-35',
         statusBg,
         statusBorder,
         statusText
       )}
+      style={{ zIndex: 35 }}
     >
       <div className="container mx-auto flex items-center gap-3 text-sm">
         {icon}

+ 1 - 1
webui/components/prompt-textarea.tsx

@@ -283,7 +283,7 @@ export function PromptTextarea({
       {showSuggestions && suggestions.length > 0 && (
         <div
           ref={suggestionsRef}
-          className="absolute mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-popover shadow-lg z-50"
+          className="absolute mt-1 w-full max-h-60 overflow-auto rounded-md border border-border bg-popover shadow-lg z-30"
         >
           {suggestions.map((suggestion, index) => (
             <div

+ 88 - 0
webui/lib/hooks.ts

@@ -0,0 +1,88 @@
+import { useState, useEffect, useCallback } from 'react';
+
+/**
+ * Custom hook for persisting form state to localStorage
+ * @param key - Unique key for localStorage
+ * @param initialValue - Initial value for the state
+ * @returns [state, setState, clearState] tuple
+ */
+export function useLocalStorage<T>(
+  key: string,
+  initialValue: T
+): [T, (value: T | ((prevValue: T) => T)) => void, () => void] {
+  // State to store our value
+  // Pass initial state function to useState so logic is only executed once
+  const [storedValue, setStoredValue] = useState<T>(() => {
+    if (typeof window === 'undefined') {
+      return initialValue;
+    }
+    try {
+      // Get from local storage by key
+      const item = window.localStorage.getItem(key);
+      // Parse stored json or if none return initialValue
+      return item ? JSON.parse(item) : initialValue;
+    } catch (error) {
+      // If error also return initialValue
+      console.warn(`Error loading localStorage key "${key}":`, error);
+      return initialValue;
+    }
+  });
+
+  // Return a wrapped version of useState's setter function that ...
+  // ... persists the new value to localStorage.
+  const setValue = useCallback(
+    (value: T | ((prevValue: T) => T)) => {
+      try {
+        // Allow value to be a function so we have same API as useState
+        const valueToStore =
+          value instanceof Function ? value(storedValue) : value;
+        // Save state
+        setStoredValue(valueToStore);
+        // Save to local storage
+        if (typeof window !== 'undefined') {
+          window.localStorage.setItem(key, JSON.stringify(valueToStore));
+        }
+      } catch (error) {
+        // A more advanced implementation would handle the error case
+        console.error(`Error saving localStorage key "${key}":`, error);
+      }
+    },
+    [key, storedValue]
+  );
+
+  // Function to clear the stored value
+  const clearValue = useCallback(() => {
+    try {
+      setStoredValue(initialValue);
+      if (typeof window !== 'undefined') {
+        window.localStorage.removeItem(key);
+      }
+    } catch (error) {
+      console.error(`Error clearing localStorage key "${key}":`, error);
+    }
+  }, [key, initialValue]);
+
+  return [storedValue, setValue, clearValue];
+}
+
+/**
+ * Hook for auto-saving form state with debouncing
+ * @param key - Unique key for localStorage
+ * @param value - Current form value
+ * @param delay - Debounce delay in milliseconds (default: 500ms)
+ */
+export function useAutoSave<T>(key: string, value: T, delay = 500) {
+  useEffect(() => {
+    const timeoutId = setTimeout(() => {
+      if (typeof window !== 'undefined') {
+        try {
+          window.localStorage.setItem(key, JSON.stringify(value));
+        } catch (error) {
+          console.error(`Error auto-saving localStorage key "${key}":`, error);
+        }
+      }
+    }, delay);
+
+    return () => clearTimeout(timeoutId);
+  }, [key, value, delay]);
+}