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 type { GitBranch, TaskAttempt, BranchStatus } from 'shared/types';
|
||||
import { openTaskForm } from '@/lib/openTaskForm';
|
||||
import { FeatureShowcaseModal } from '@/components/showcase/FeatureShowcaseModal';
|
||||
import { FeatureShowcaseDialog } from '@/components/dialogs/global/FeatureShowcaseDialog';
|
||||
import { showcases } from '@/config/showcases';
|
||||
import { useShowcaseTrigger } from '@/hooks/useShowcaseTrigger';
|
||||
import { useUserSystem } from '@/components/config-provider';
|
||||
import { usePostHog } from 'posthog-js/react';
|
||||
|
||||
import { useSearch } from '@/contexts/search-context';
|
||||
@@ -201,10 +201,31 @@ export function ProjectTasks() {
|
||||
const isSharedPanelOpen = Boolean(selectedSharedTask);
|
||||
const isPanelOpen = isTaskPanelOpen || isSharedPanelOpen;
|
||||
|
||||
const { isOpen: showTaskPanelShowcase, close: closeTaskPanelShowcase } =
|
||||
useShowcaseTrigger(showcases.taskPanel, {
|
||||
enabled: isPanelOpen,
|
||||
const { config, updateAndSaveConfig, loading } = useUserSystem();
|
||||
|
||||
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 { data: attempts = [], isLoading: isAttemptsLoading } = useTaskAttempts(
|
||||
@@ -1049,11 +1070,6 @@ export function ProjectTasks() {
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0">{attemptArea}</div>
|
||||
<FeatureShowcaseModal
|
||||
isOpen={showTaskPanelShowcase}
|
||||
onClose={closeTaskPanelShowcase}
|
||||
config={showcases.taskPanel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user