feat(phase7.1): add HelpTooltip system with contextual help icons
- New HelpTooltip component: hover-triggered floating panel, themed via CSS variables, supports top/right/bottom/left positioning, no deps - New helpTexts.ts registry: 14 entries covering render settings, admin actions, template fields, and wizard fields - Admin.tsx: tooltips on Cycles/EEVEE samples, smooth angle, regenerate thumbnails, process unprocessed - RenderTemplateTable.tsx: tooltips on material replace, lighting only, shadow catcher column headers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
import { HelpCircle } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { HELP_TEXTS, type HelpText } from '../help/helpTexts'
|
||||||
|
|
||||||
|
interface HelpTooltipProps {
|
||||||
|
helpKey?: string
|
||||||
|
help?: HelpText
|
||||||
|
position?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelpTooltip({
|
||||||
|
helpKey,
|
||||||
|
help: directHelp,
|
||||||
|
position = 'right',
|
||||||
|
size = 14,
|
||||||
|
}: HelpTooltipProps) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const help = directHelp ?? (helpKey ? HELP_TEXTS[helpKey] : undefined)
|
||||||
|
|
||||||
|
if (!help) return null
|
||||||
|
|
||||||
|
const tooltipPositionStyle: React.CSSProperties = (() => {
|
||||||
|
switch (position) {
|
||||||
|
case 'top':
|
||||||
|
return { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 6 }
|
||||||
|
case 'bottom':
|
||||||
|
return { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: 6 }
|
||||||
|
case 'left':
|
||||||
|
return { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: 6 }
|
||||||
|
case 'right':
|
||||||
|
default:
|
||||||
|
return { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: 6 }
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="relative inline-flex items-center"
|
||||||
|
onMouseEnter={() => setVisible(true)}
|
||||||
|
onMouseLeave={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
<HelpCircle
|
||||||
|
size={size}
|
||||||
|
className="text-content-muted hover:text-content-secondary transition-colors cursor-help"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{visible && (
|
||||||
|
<div
|
||||||
|
className="absolute z-50 rounded-lg border shadow-lg p-3 text-sm"
|
||||||
|
style={{
|
||||||
|
...tooltipPositionStyle,
|
||||||
|
backgroundColor: 'var(--color-bg-surface, var(--color-surface, #fff))',
|
||||||
|
borderColor: 'var(--color-border-default, #e5e7eb)',
|
||||||
|
color: 'var(--color-text-content, var(--color-content, #111827))',
|
||||||
|
maxWidth: 280,
|
||||||
|
minWidth: 180,
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="font-semibold mb-1" style={{ color: 'var(--color-text-content, inherit)' }}>
|
||||||
|
{help.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed" style={{ color: 'var(--color-content-secondary, #6b7280)' }}>
|
||||||
|
{help.body}
|
||||||
|
</p>
|
||||||
|
{help.unit && help.range && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--color-content-muted, #9ca3af)' }}>
|
||||||
|
Range: {help.range[0]}–{help.range[1]} {help.unit}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{help.recommendation && (
|
||||||
|
<p
|
||||||
|
className="text-xs mt-1.5 px-2 py-1 rounded"
|
||||||
|
style={{ backgroundColor: 'rgba(16, 185, 129, 0.1)', color: 'var(--color-status-success-text, #065f46)' }}
|
||||||
|
>
|
||||||
|
Recommended: {help.recommendation}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{help.warning && (
|
||||||
|
<p
|
||||||
|
className="text-xs mt-1.5 px-2 py-1 rounded"
|
||||||
|
style={{ backgroundColor: 'rgba(245, 158, 11, 0.1)', color: 'var(--color-status-warning-text, #92400e)' }}
|
||||||
|
>
|
||||||
|
Warning: {help.warning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HelpTooltip
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
|
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
|
||||||
|
import HelpTooltip from '../HelpTooltip'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
listRenderTemplates,
|
listRenderTemplates,
|
||||||
@@ -165,9 +166,24 @@ export default function RenderTemplateTable() {
|
|||||||
<th className="px-3 py-2 font-medium">Category</th>
|
<th className="px-3 py-2 font-medium">Category</th>
|
||||||
<th className="px-3 py-2 font-medium">Output Type</th>
|
<th className="px-3 py-2 font-medium">Output Type</th>
|
||||||
<th className="px-3 py-2 font-medium">Collection</th>
|
<th className="px-3 py-2 font-medium">Collection</th>
|
||||||
<th className="px-3 py-2 font-medium">Mat. Replace</th>
|
<th className="px-3 py-2 font-medium">
|
||||||
<th className="px-3 py-2 font-medium">Lighting Only</th>
|
<span className="inline-flex items-center gap-1">
|
||||||
<th className="px-3 py-2 font-medium" title="Enable Shadowcatcher collection (Cycles only)">Shadow Catcher</th>
|
Mat. Replace
|
||||||
|
<HelpTooltip helpKey="template.material_replace_enabled" position="bottom" size={12} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Lighting Only
|
||||||
|
<HelpTooltip helpKey="template.lighting_only" position="bottom" size={12} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 font-medium">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
Shadow Catcher
|
||||||
|
<HelpTooltip helpKey="template.shadow_catcher" position="bottom" size={12} />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</th>
|
<th className="px-3 py-2 font-medium" title="Rotate camera around product instead of product rotation (faster GPU rendering)">Cam Orbit</th>
|
||||||
<th className="px-3 py-2 font-medium">.blend File</th>
|
<th className="px-3 py-2 font-medium">.blend File</th>
|
||||||
<th className="px-3 py-2 font-medium">Active</th>
|
<th className="px-3 py-2 font-medium">Active</th>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
export interface HelpText {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
warning?: string
|
||||||
|
recommendation?: string
|
||||||
|
unit?: string
|
||||||
|
range?: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HELP_TEXTS: Record<string, HelpText> = {
|
||||||
|
// Render settings
|
||||||
|
'setting.blender_cycles_samples': {
|
||||||
|
title: 'Cycles Samples',
|
||||||
|
body: 'Number of render samples per pixel. Higher = better quality, longer render time.',
|
||||||
|
recommendation: '256 for production, 64 for quick previews',
|
||||||
|
unit: 'samples',
|
||||||
|
range: [1, 4096],
|
||||||
|
},
|
||||||
|
'setting.blender_eevee_samples': {
|
||||||
|
title: 'EEVEE Samples',
|
||||||
|
body: 'Anti-aliasing samples for EEVEE Next renderer. EEVEE is much faster than Cycles but less photorealistic.',
|
||||||
|
recommendation: '64 for standard quality',
|
||||||
|
unit: 'samples',
|
||||||
|
range: [1, 1024],
|
||||||
|
},
|
||||||
|
'setting.blender_smooth_angle': {
|
||||||
|
title: 'Smooth Shading Angle',
|
||||||
|
body: 'Angle threshold for auto-smooth shading on imported meshes. Below this angle, faces appear smooth; above it, edges show a sharp crease.',
|
||||||
|
recommendation: '30° works well for most bearings',
|
||||||
|
unit: 'degrees',
|
||||||
|
range: [0, 180],
|
||||||
|
},
|
||||||
|
'setting.thumbnail_renderer': {
|
||||||
|
title: 'Thumbnail Renderer',
|
||||||
|
body: 'Which renderer to use for STEP file thumbnails. Blender produces photorealistic results; Three.js is faster but lower quality.',
|
||||||
|
recommendation: 'Use Blender for production, Three.js for fast previews',
|
||||||
|
},
|
||||||
|
'setting.stl_quality': {
|
||||||
|
title: 'STL Export Quality',
|
||||||
|
body: 'Controls tessellation precision when converting STEP to STL for older render paths. High quality = finer mesh, larger file, slower conversion.',
|
||||||
|
recommendation: 'Low is sufficient for most thumbnails',
|
||||||
|
},
|
||||||
|
'action.regenerate_thumbnails': {
|
||||||
|
title: 'Regenerate All Thumbnails',
|
||||||
|
body: 'Re-renders thumbnails for all STEP files using current renderer settings. Queues every file on the thumbnail_rendering worker.',
|
||||||
|
warning: 'This queues a large number of tasks. Only run during off-peak hours.',
|
||||||
|
},
|
||||||
|
'action.process_unprocessed': {
|
||||||
|
title: 'Process Unprocessed',
|
||||||
|
body: "Queues all STEP files that have status 'pending' or 'failed' for metadata extraction and thumbnail rendering.",
|
||||||
|
},
|
||||||
|
'action.generate_missing_stls': {
|
||||||
|
title: 'Generate Missing STL Caches',
|
||||||
|
body: "Creates STL cache files for STEP files that don't have them yet. STL caches speed up repeated renders.",
|
||||||
|
},
|
||||||
|
'action.seed_aliases': {
|
||||||
|
title: 'Seed Material Aliases',
|
||||||
|
body: 'Loads the default Schaeffler material alias mappings (Steel→SCHAEFFLER_010101_Steel-Bare, etc). Safe to run multiple times — existing aliases are not overwritten.',
|
||||||
|
},
|
||||||
|
// Template fields
|
||||||
|
'template.lighting_only': {
|
||||||
|
title: 'Lighting Only Mode',
|
||||||
|
body: 'When enabled, this template provides only the world/HDRI lighting environment. The camera position is computed automatically from the product bounding box, ignoring any camera set up in the .blend file.',
|
||||||
|
recommendation: 'Use for HDR light-setup templates where you want auto-framing',
|
||||||
|
},
|
||||||
|
'template.shadow_catcher': {
|
||||||
|
title: 'Shadow Catcher',
|
||||||
|
body: "Adds a transparent ground plane that catches shadows from the product. Requires a 'Shadowcatcher' collection in the .blend file. Enables the transparent film option.",
|
||||||
|
},
|
||||||
|
'template.material_replace_enabled': {
|
||||||
|
title: 'Material Replacement',
|
||||||
|
body: 'When enabled, Blender will replace part materials with the mapped Schaeffler library materials. When disabled, the original .blend materials are used.',
|
||||||
|
},
|
||||||
|
// Wizard fields
|
||||||
|
'wizard.output_type': {
|
||||||
|
title: 'Output Type',
|
||||||
|
body: 'Defines what to render: still image, turntable animation, or custom output. Each output type has its own render settings and pricing.',
|
||||||
|
},
|
||||||
|
'wizard.render_template': {
|
||||||
|
title: 'Render Template',
|
||||||
|
body: 'A .blend file that provides lighting, camera setup, and optionally a background environment. Templates are matched by product category.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { UserPlus, Trash2, Pencil, ChevronDown, ChevronUp, ChevronRight, Setting
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import ConfirmModal from '../components/ConfirmModal'
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
|
import HelpTooltip from '../components/HelpTooltip'
|
||||||
import TemplateEditor from '../components/admin/TemplateEditor'
|
import TemplateEditor from '../components/admin/TemplateEditor'
|
||||||
import PricingTierTable from '../components/admin/PricingTierTable'
|
import PricingTierTable from '../components/admin/PricingTierTable'
|
||||||
import OutputTypeTable from '../components/admin/OutputTypeTable'
|
import OutputTypeTable from '../components/admin/OutputTypeTable'
|
||||||
@@ -388,7 +389,10 @@ export default function AdminPage() {
|
|||||||
{/* Sample counts */}
|
{/* Sample counts */}
|
||||||
<div className="grid grid-cols-2 gap-4 max-w-sm">
|
<div className="grid grid-cols-2 gap-4 max-w-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-content-secondary mb-1">Cycles samples</label>
|
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
|
||||||
|
Cycles samples
|
||||||
|
<HelpTooltip helpKey="setting.blender_cycles_samples" position="top" size={12} />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={4096} step={32}
|
min={1} max={4096} step={32}
|
||||||
@@ -400,7 +404,10 @@ export default function AdminPage() {
|
|||||||
<p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p>
|
<p className="text-xs text-content-muted mt-0.5">Higher = better quality, slower</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-content-secondary mb-1">EEVEE samples</label>
|
<label className="flex items-center gap-1 text-xs font-medium text-content-secondary mb-1">
|
||||||
|
EEVEE samples
|
||||||
|
<HelpTooltip helpKey="setting.blender_eevee_samples" position="top" size={12} />
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={1024} step={16}
|
min={1} max={1024} step={16}
|
||||||
@@ -415,7 +422,10 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{/* Smooth by angle */}
|
{/* Smooth by angle */}
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Smooth angle</span>
|
<span className="flex items-center gap-1 text-sm font-medium text-content-secondary w-28 shrink-0">
|
||||||
|
Smooth angle
|
||||||
|
<HelpTooltip helpKey="setting.blender_smooth_angle" size={12} />
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0} max={180} step={5}
|
min={0} max={180} step={5}
|
||||||
@@ -674,27 +684,33 @@ export default function AdminPage() {
|
|||||||
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
|
<p className="text-xs text-content-muted">Resets files stuck in 'processing' to 'failed'. Runs automatically every 5 min.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => processUnprocessedMut.mutate()}
|
onClick={() => processUnprocessedMut.mutate()}
|
||||||
disabled={processUnprocessedMut.isPending}
|
disabled={processUnprocessedMut.isPending}
|
||||||
className="btn-secondary text-sm w-full justify-start"
|
className="btn-secondary text-sm flex-1 justify-start"
|
||||||
title="Queue all pending and failed STEP files that have never been successfully processed"
|
title="Queue all pending and failed STEP files that have never been successfully processed"
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
|
<RefreshCw size={14} className={processUnprocessedMut.isPending ? 'animate-spin' : ''} />
|
||||||
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
|
{processUnprocessedMut.isPending ? 'Queueing…' : 'Process Unprocessed'}
|
||||||
</button>
|
</button>
|
||||||
|
<HelpTooltip helpKey="action.process_unprocessed" position="left" />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
|
<p className="text-xs text-content-muted">Queues all pending/failed STEP files for initial processing.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => regenerateMut.mutate()}
|
onClick={() => regenerateMut.mutate()}
|
||||||
disabled={regenerateMut.isPending}
|
disabled={regenerateMut.isPending}
|
||||||
className="btn-secondary text-sm w-full justify-start"
|
className="btn-secondary text-sm flex-1 justify-start"
|
||||||
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
|
title="Re-render thumbnails for all completed CAD files using the current Blender settings"
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
|
<RefreshCw size={14} className={regenerateMut.isPending ? 'animate-spin' : ''} />
|
||||||
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
|
{regenerateMut.isPending ? 'Re-queuing…' : 'Regenerate All Thumbnails'}
|
||||||
</button>
|
</button>
|
||||||
|
<HelpTooltip helpKey="action.regenerate_thumbnails" position="left" />
|
||||||
|
</div>
|
||||||
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
|
<p className="text-xs text-content-muted">Re-renders thumbnails for all completed CAD files.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user