Refactor FeatureShowcaseModal to use lib/modals (vibe-kanban) (#1308)
* Perfect! All type checks pass. The refactoring is complete.
## Summary
Successfully refactored [FeatureShowcaseModal.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/components/showcase/FeatureShowcaseModal.tsx) to use the `lib/modals` pattern:
**Changes made:**
1. **FeatureShowcaseModal.tsx:**
- Wrapped component with `NiceModal.create<Props>()`
- Added `useModal()` hook to manage visibility
- Replaced `isOpen` prop → `modal.visible`
- Replaced `onClose()` → `modal.hide()`
- Added `onExitComplete` to AnimatePresence for proper cleanup
- Updated keyboard scope effect to restore scopes only on cleanup
- Exported via `defineModal<Props, void>()`
2. **project-tasks.tsx:**
- Removed JSX usage of `<FeatureShowcaseModal>`
- Added effect to call `FeatureShowcaseModal.show()` when trigger fires
- Properly handles cleanup with `.finally()` callback
All type checks pass ✓
* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236
* Perfect! Refactoring complete.
## Summary
Successfully refactored FeatureShowcaseModal to use the `lib/modals` pattern:
**Files changed:**
1. **Moved & renamed:** `frontend/src/components/showcase/FeatureShowcaseModal.tsx` → [frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx)
- Wrapped with `NiceModal.create<Props>()`
- Uses `useModal()` for visibility control
- Exports via `defineModal<Props, void>()`
- Updated keyboard scope management to restore on cleanup only
2. **Updated:** [frontend/src/pages/project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx)
- Removed JSX usage
- Calls `FeatureShowcaseDialog.show()` when trigger fires
- Properly handles cleanup with `.finally()` callback
All type checks and linting pass ✓
* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236
* Refactor FeatureShowcaseDialog to use Dialog component (vibe-kanban 8a3e4ceb)
Use the Dialog component, like all the other dialogs/modals
* Perfect! Refactoring complete.
## Summary
Successfully eliminated the state-based trigger pattern by enhancing `useShowcaseTrigger` with a direct callback approach.
**Changes made:**
1. **[useShowcaseTrigger.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/hooks/useShowcaseTrigger.ts):**
- Added `onShow?: () => Promise<unknown>` to options
- Stored callback in ref to avoid stale closures
- Calls `onShow()` directly when conditions are met
- Automatically calls `close()` when promise resolves (marks as seen)
2. **[project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Removed `useEffect` that watched `isOpen` state
- Removed manual `close()` call
- Passes `FeatureShowcaseDialog.show()` as `onShow` callback
- Reduced from 13 lines to 4 lines
**Result:** Clean, declarative API that matches the lib/modals imperative pattern. No more state to trigger side effects.
All type checks pass ✓
* Perfect! The persistence tracking is now fixed.
## Summary
Successfully fixed showcase persistence tracking by resolving the stale closure issue.
**Changes to [useShowcaseTrigger.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/hooks/useShowcaseTrigger.ts):**
1. **Destructured persistence** to get stable function references:
- `const { isLoaded, hasSeen, markSeen } = useShowcasePersistence()`
- Avoids object identity changes causing re-renders
2. **Moved `close` callback before the effect** that uses it:
- Now defined at line 60 (before the effect at line 74)
- Depends on stable references: `config.id`, `markSeenOnClose`, `markSeen`
3. **Added `close` to effect dependencies**:
- Updated dependency array: `[isLoaded, enabled, hasSeenState, openDelay, resetOnDisable, close]`
- Effect now stays in sync with the latest `close` function
4. **Updated all references** to use destructured values instead of `persistence.*`
**Result:** When the dialog closes, `markSeen(config.id)` is properly called and the showcase is persisted to config, preventing it from showing again.
All type checks pass ✓
* Perfect! Successfully eliminated the `useShowcaseTrigger` hook.
## Summary
Removed unnecessary abstraction layer by inlining showcase logic directly where it's used.
**Changes:**
1. **[project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Replaced `useShowcaseTrigger` with direct `useShowcasePersistence` usage
- Added inline effect that:
- Checks if showcase has been seen
- Waits 300ms before showing (prevents flash during mount)
- Shows `FeatureShowcaseDialog` when panel opens for first time
- Marks as seen when dialog closes
- Properly cleans up timer with `cancelled` flag
- Much clearer: logic is visible at point of use
2. **Deleted:** `frontend/src/hooks/useShowcaseTrigger.ts` (no longer needed)
**Result:**
- **Simpler:** 20 lines of clear, inline logic vs. 127-line hook file
- **More readable:** No indirection - you can see exactly what happens when
- **Same behavior:** Persistence tracking, delay, proper cleanup all preserved
All type checks pass ✓
* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236
* Perfect! Successfully eliminated the `useShowcasePersistence` hook.
Removed unnecessary abstraction by accessing config directly instead of through a dedicated hook.
**Changes:**
1. **[project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Replaced `useShowcasePersistence()` with direct `useUserSystem()` call
- Direct access to `config`, `updateAndSaveConfig`, `loading`
- Inline computation of `seenFeatures` array and `seen` boolean
- Updated config directly in effect's finally block
- Added idempotent guard (`seenFeatures.includes(showcaseId)`) before saving
2. **Deleted:** `frontend/src/hooks/useShowcasePersistence.ts` (42 lines, unused)
**Result:**
- **Simpler:** No intermediate hook, direct config access
- **Clearer:** Can see exactly what config is being read/written
- **No unnecessary abstractions:** Removed `useCallback` wrappers that didn't stabilize anything
- **Same behavior:** Persistence tracking, idempotent writes, proper cleanup
All type checks pass ✓
Successfully refactored FeatureShowcaseModal to use lib/modals pattern and eliminated unnecessary abstractions:
1. ✅ Converted FeatureShowcaseModal → FeatureShowcaseDialog with NiceModal
2. ✅ Moved to dialogs/global/ directory
3. ✅ Fixed keyboard scope management
4. ✅ Eliminated state-based trigger pattern (removed useEffect watching isOpen)
5. ✅ Removed useShowcaseTrigger hook (127 lines → inline)
6. ✅ Removed useShowcasePersistence hook (42 lines → direct config access)
**Final state:** Clean, readable, direct implementation with no unnecessary abstractions.
* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236
* Perfect! Removed the unnecessary timeout.
## Summary
Simplified the showcase effect by removing the 300ms delay.
**Change to [project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Removed `setTimeout` wrapper and 300ms delay
- Removed timer cleanup from return statement
- Kept `cancelled` flag to prevent config update after unmount
- Dialog now shows immediately when conditions are met
**Result:** Simpler, more direct code - showcase appears immediately when panel opens for the first time.
All type checks pass ✓
* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236
* Perfect! The showcase logic is now extremely clean and simple.
## Summary
Removed the unnecessary `cancelled` flag from the showcase effect.
**Change to [project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/dd42-refactor-feature/frontend/src/pages/project-tasks.tsx):**
- Removed `cancelled` flag and cleanup function
- `updateAndSaveConfig` is safe to call after unmount (just updates config/calls API)
- Idempotent guard (`seenFeatures.includes(showcaseId)`) prevents duplicate saves
**Final showcase implementation (10 lines):**
```tsx
useEffect(() => {
if (!isLoaded || !isPanelOpen || seen) return;
FeatureShowcaseDialog.show({ config: showcases.taskPanel }).finally(() => {
if (seenFeatures.includes(showcaseId)) return;
void updateAndSaveConfig({
showcases: { seen_features: [...seenFeatures, showcaseId] },
});
});
}, [isLoaded, isPanelOpen, seen, showcaseId, updateAndSaveConfig, seenFeatures]);
```
All type checks pass ✓
## Complete Refactoring Results
Transformed the feature showcase system from complex abstractions to simple, direct code:
- ✅ Converted to lib/modals pattern (NiceModal)
- ✅ Eliminated 2 unnecessary hooks (169 lines removed)
- ✅ Removed unnecessary timeout and cancellation logic
- ✅ Final result: **10 lines of clear, direct code** that shows dialog and saves config
* Cleanup script changes for task attempt dd42978a-2a1a-44bd-9e2e-aa0c6575d236
* hide and save properly
This commit is contained in:
committed by
GitHub
parent
81b92c6ec3
commit
18ae6c5fd6
146
frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx
Normal file
146
frontend/src/components/dialogs/global/FeatureShowcaseDialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ChevronRight, ChevronLeft } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||||
|
import { defineModal } from '@/lib/modals';
|
||||||
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
import { ShowcaseStageMedia } from '@/components/showcase/ShowcaseStageMedia';
|
||||||
|
import type { ShowcaseConfig } from '@/types/showcase';
|
||||||
|
|
||||||
|
interface FeatureShowcaseDialogProps {
|
||||||
|
config: ShowcaseConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FeatureShowcaseDialog - Generic multi-stage modal for showcasing features with media
|
||||||
|
*
|
||||||
|
* Displays a modal with stages containing videos or images, title, description,
|
||||||
|
* and navigation controls. ESC key is disabled; only Next/Finish buttons dismiss.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Multi-stage or single-stage support (hides navigation if 1 stage)
|
||||||
|
* - Video support with loading states and progress bars
|
||||||
|
* - Image support with loading skeleton
|
||||||
|
* - Responsive design (full-width on mobile, 2/3 width on desktop)
|
||||||
|
* - i18n support via translation keys
|
||||||
|
* - Smooth transitions between stages
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```ts
|
||||||
|
* FeatureShowcaseDialog.show({ config: showcases.taskPanel });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const FeatureShowcaseDialogImpl = NiceModal.create<FeatureShowcaseDialogProps>(
|
||||||
|
({ config }: FeatureShowcaseDialogProps) => {
|
||||||
|
const modal = useModal();
|
||||||
|
const [currentStage, setCurrentStage] = useState(0);
|
||||||
|
const { t } = useTranslation('tasks');
|
||||||
|
|
||||||
|
const stage = config.stages[currentStage];
|
||||||
|
const totalStages = config.stages.length;
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
setCurrentStage((prev) => {
|
||||||
|
if (prev >= totalStages - 1) {
|
||||||
|
modal.resolve();
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return prev + 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setCurrentStage((prev) => Math.max(prev - 1, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
modal.hide();
|
||||||
|
modal.resolve();
|
||||||
|
modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={modal.visible}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
uncloseable
|
||||||
|
className="max-w-none xl:max-w-[min(66.66vw,calc((100svh-20rem)*1.6))] p-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<DialogContent className="p-0 gap-0">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentStage}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<ShowcaseStageMedia media={stage.media} />
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">
|
||||||
|
{t(stage.titleKey)}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{currentStage + 1} / {totalStages}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{t(stage.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{Array.from({ length: totalStages }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||||
|
index === currentStage ? 'bg-primary' : 'bg-muted'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalStages > 1 && (
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{currentStage > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
{t('showcases.buttons.previous')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 border border-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{currentStage === totalStages - 1
|
||||||
|
? t('showcases.buttons.finish')
|
||||||
|
: t('showcases.buttons.next')}
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FeatureShowcaseDialog = defineModal<
|
||||||
|
FeatureShowcaseDialogProps,
|
||||||
|
void
|
||||||
|
>(FeatureShowcaseDialogImpl);
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { ChevronRight, ChevronLeft } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
|
||||||
import { useKeyExit, Scope } from '@/keyboard';
|
|
||||||
import { ShowcaseStageMedia } from './ShowcaseStageMedia';
|
|
||||||
import type { ShowcaseConfig } from '@/types/showcase';
|
|
||||||
|
|
||||||
interface FeatureShowcaseModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
config: ShowcaseConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FeatureShowcaseModal - Generic multi-stage modal for showcasing features with media
|
|
||||||
*
|
|
||||||
* Displays a bottom-aligned modal with stages containing videos or images, title, description,
|
|
||||||
* and navigation controls. Properly manages keyboard shortcuts (ESC captured but disabled)
|
|
||||||
* and scopes to prevent closing underlying features.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Multi-stage or single-stage support (hides navigation if 1 stage)
|
|
||||||
* - Video support with loading states and progress bars
|
|
||||||
* - Image support with loading skeleton
|
|
||||||
* - Responsive design (full-width on mobile, 2/3 width on desktop)
|
|
||||||
* - i18n support via translation keys
|
|
||||||
* - Smooth transitions between stages
|
|
||||||
*
|
|
||||||
* @param isOpen - Controls modal visibility
|
|
||||||
* @param onClose - Called when user finishes the showcase (via Finish button on last stage)
|
|
||||||
* @param config - ShowcaseConfig object defining stages, media, and translation keys
|
|
||||||
*/
|
|
||||||
export function FeatureShowcaseModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
config,
|
|
||||||
}: FeatureShowcaseModalProps) {
|
|
||||||
const [currentStage, setCurrentStage] = useState(0);
|
|
||||||
const { t } = useTranslation('tasks');
|
|
||||||
const { enableScope, disableScope, activeScopes } = useHotkeysContext();
|
|
||||||
const previousScopesRef = useRef<string[]>([]);
|
|
||||||
|
|
||||||
const stage = config.stages[currentStage];
|
|
||||||
const totalStages = config.stages.length;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scope management for keyboard shortcuts:
|
|
||||||
* When showcase opens, we capture all currently active scopes, disable them,
|
|
||||||
* and enable only DIALOG scope. This ensures ESC key presses are captured by
|
|
||||||
* our showcase handler (which does nothing) instead of triggering underlying
|
|
||||||
* close handlers. When closing, we restore the original scopes.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
previousScopesRef.current = activeScopes;
|
|
||||||
activeScopes.forEach((scope) => disableScope(scope));
|
|
||||||
enableScope(Scope.DIALOG);
|
|
||||||
} else {
|
|
||||||
disableScope(Scope.DIALOG);
|
|
||||||
previousScopesRef.current.forEach((scope) => enableScope(scope));
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disableScope(Scope.DIALOG);
|
|
||||||
previousScopesRef.current.forEach((scope) => enableScope(scope));
|
|
||||||
};
|
|
||||||
// activeScopes intentionally omitted - we only capture on open, not on every scope change
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isOpen, enableScope, disableScope]);
|
|
||||||
|
|
||||||
useKeyExit(
|
|
||||||
(e) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
},
|
|
||||||
{ scope: Scope.DIALOG, enabled: isOpen }
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleNext = () => {
|
|
||||||
setCurrentStage((prev) => {
|
|
||||||
if (prev >= totalStages - 1) {
|
|
||||||
onClose();
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return prev + 1;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = () => {
|
|
||||||
setCurrentStage((prev) => Math.max(prev - 1, 0));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 bg-black/50 z-[9998]"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 100 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 100 }}
|
|
||||||
transition={{ duration: 0.3, ease: [0.2, 0, 0, 1] }}
|
|
||||||
className="fixed bottom-4 left-0 right-0 mx-4 w-auto xl:bottom-8 xl:left-0 xl:right-0 xl:mx-auto xl:w-full xl:max-w-[min(66.66vw,calc((100svh-20rem)*1.6))] bg-card border border-border rounded-lg shadow-2xl overflow-hidden z-[9999]"
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={currentStage}
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
|
||||||
<ShowcaseStageMedia media={stage.media} />
|
|
||||||
|
|
||||||
<div className="p-6 space-y-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-lg font-semibold text-foreground">
|
|
||||||
{t(stage.titleKey)}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
||||||
{currentStage + 1} / {totalStages}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
{t(stage.descriptionKey)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{Array.from({ length: totalStages }).map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
|
||||||
index === currentStage ? 'bg-primary' : 'bg-muted'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalStages > 1 && (
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
|
||||||
{currentStage > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handlePrevious}
|
|
||||||
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium border border-input hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
{t('showcases.buttons.previous')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={handleNext}
|
|
||||||
className="h-10 px-4 py-2 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 border border-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{currentStage === totalStages - 1
|
|
||||||
? t('showcases.buttons.finish')
|
|
||||||
: t('showcases.buttons.next')}
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { useUserSystem } from '@/components/config-provider';
|
|
||||||
|
|
||||||
export interface ShowcasePersistence {
|
|
||||||
hasSeen: (id: string) => boolean;
|
|
||||||
markSeen: (id: string) => Promise<void>;
|
|
||||||
isLoaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShowcasePersistence(): ShowcasePersistence {
|
|
||||||
const { config, updateAndSaveConfig, loading } = useUserSystem();
|
|
||||||
|
|
||||||
const seenFeatures = useMemo(
|
|
||||||
() => config?.showcases?.seen_features ?? [],
|
|
||||||
[config?.showcases?.seen_features]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSeen = useCallback(
|
|
||||||
(id: string): boolean => {
|
|
||||||
return seenFeatures.includes(id);
|
|
||||||
},
|
|
||||||
[seenFeatures]
|
|
||||||
);
|
|
||||||
|
|
||||||
const markSeen = useCallback(
|
|
||||||
async (id: string): Promise<void> => {
|
|
||||||
if (seenFeatures.includes(id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateAndSaveConfig({
|
|
||||||
showcases: {
|
|
||||||
seen_features: [...seenFeatures, id],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[seenFeatures, updateAndSaveConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasSeen,
|
|
||||||
markSeen,
|
|
||||||
isLoaded: !loading,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import type { ShowcaseConfig } from '@/types/showcase';
|
|
||||||
import { useShowcasePersistence } from './useShowcasePersistence';
|
|
||||||
|
|
||||||
export interface ShowcaseTriggerOptions {
|
|
||||||
enabled: boolean;
|
|
||||||
openDelay?: number;
|
|
||||||
resetOnDisable?: boolean;
|
|
||||||
markSeenOnClose?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShowcaseTriggerResult {
|
|
||||||
isOpen: boolean;
|
|
||||||
open: () => void;
|
|
||||||
close: () => void;
|
|
||||||
hasSeen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShowcaseTrigger(
|
|
||||||
config: ShowcaseConfig,
|
|
||||||
options: ShowcaseTriggerOptions
|
|
||||||
): ShowcaseTriggerResult {
|
|
||||||
const {
|
|
||||||
enabled,
|
|
||||||
openDelay = 300,
|
|
||||||
resetOnDisable = true,
|
|
||||||
markSeenOnClose = true,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const persistence = useShowcasePersistence();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [hasSeenState, setHasSeenState] = useState(false);
|
|
||||||
const timerRef = useRef<number | null>(null);
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
|
|
||||||
// Keep 'hasSeenState' in sync if id change or config loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (!persistence.isLoaded) return;
|
|
||||||
setHasSeenState(persistence.hasSeen(config.id));
|
|
||||||
}, [persistence.isLoaded, config.id, persistence]);
|
|
||||||
|
|
||||||
// Cleanup timers
|
|
||||||
useEffect(() => {
|
|
||||||
mountedRef.current = true;
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false;
|
|
||||||
if (timerRef.current !== null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle enabled state changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!persistence.isLoaded) return;
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
// Only show if not seen
|
|
||||||
if (!hasSeenState) {
|
|
||||||
// Clear any existing timer
|
|
||||||
if (timerRef.current !== null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay opening to ensure UI is mounted
|
|
||||||
timerRef.current = window.setTimeout(() => {
|
|
||||||
if (mountedRef.current) {
|
|
||||||
setIsOpen(true);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
}, openDelay);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Reset when disabled (if configured)
|
|
||||||
if (resetOnDisable) {
|
|
||||||
// Clear pending timer
|
|
||||||
if (timerRef.current !== null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current !== null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [persistence.isLoaded, enabled, hasSeenState, openDelay, resetOnDisable]);
|
|
||||||
|
|
||||||
const open = useCallback(() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const close = useCallback(() => {
|
|
||||||
if (markSeenOnClose) {
|
|
||||||
persistence.markSeen(config.id);
|
|
||||||
setHasSeenState(true);
|
|
||||||
}
|
|
||||||
if (timerRef.current !== null) {
|
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
}, [config.id, markSeenOnClose, persistence]);
|
|
||||||
|
|
||||||
return { isOpen, open, close, hasSeen: hasSeenState };
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,9 @@ import { Loader } from '@/components/ui/loader';
|
|||||||
import { tasksApi } from '@/lib/api';
|
import { tasksApi } from '@/lib/api';
|
||||||
import type { GitBranch, TaskAttempt, BranchStatus } from 'shared/types';
|
import type { GitBranch, TaskAttempt, BranchStatus } from 'shared/types';
|
||||||
import { openTaskForm } from '@/lib/openTaskForm';
|
import { openTaskForm } from '@/lib/openTaskForm';
|
||||||
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
|
import { FeatureShowcaseDialog } from '@/components/dialogs/global/FeatureShowcaseDialog';
|
||||||
import { showcases } from '@/config/showcases';
|
import { showcases } from '@/config/showcases';
|
||||||
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
|
import { useUserSystem } from '@/components/config-provider';
|
||||||
import { usePostHog } from 'posthog-js/react';
|
import { usePostHog } from 'posthog-js/react';
|
||||||
|
|
||||||
import { useSearch } from '@/contexts/search-context';
|
import { useSearch } from '@/contexts/search-context';
|
||||||
@@ -201,10 +201,31 @@ export function ProjectTasks() {
|
|||||||
const isSharedPanelOpen = Boolean(selectedSharedTask);
|
const isSharedPanelOpen = Boolean(selectedSharedTask);
|
||||||
const isPanelOpen = isTaskPanelOpen || isSharedPanelOpen;
|
const isPanelOpen = isTaskPanelOpen || isSharedPanelOpen;
|
||||||
|
|
||||||
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
|
const { config, updateAndSaveConfig, loading } = useUserSystem();
|
||||||
useShowcaseTrigger(showcases.taskPanel, {
|
|
||||||
enabled: isPanelOpen,
|
const isLoaded = !loading;
|
||||||
|
const showcaseId = showcases.taskPanel.id;
|
||||||
|
const seenFeatures = config?.showcases?.seen_features ?? [];
|
||||||
|
const seen = isLoaded && seenFeatures.includes(showcaseId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded || !isPanelOpen || seen) return;
|
||||||
|
|
||||||
|
FeatureShowcaseDialog.show({ config: showcases.taskPanel }).finally(() => {
|
||||||
|
FeatureShowcaseDialog.hide();
|
||||||
|
if (seenFeatures.includes(showcaseId)) return;
|
||||||
|
void updateAndSaveConfig({
|
||||||
|
showcases: { seen_features: [...seenFeatures, showcaseId] },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
isLoaded,
|
||||||
|
isPanelOpen,
|
||||||
|
seen,
|
||||||
|
showcaseId,
|
||||||
|
updateAndSaveConfig,
|
||||||
|
seenFeatures,
|
||||||
|
]);
|
||||||
|
|
||||||
const isLatest = attemptId === 'latest';
|
const isLatest = attemptId === 'latest';
|
||||||
const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts(
|
const { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts(
|
||||||
@@ -1049,11 +1070,6 @@ export function ProjectTasks() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">{attemptArea}</div>
|
<div className="flex-1 min-h-0">{attemptArea}</div>
|
||||||
<FeatureShowcaseModal
|
|
||||||
isOpen={showTaskPanelShowcase}
|
|
||||||
onClose={closeTaskPanelShowcase}
|
|
||||||
config={showcases.taskPanel}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user