summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/__tests__/README.md164
-rw-r--r--apps/web/src/__tests__/editor-page-integration.test.tsx342
-rw-r--r--apps/web/src/__tests__/excessive-projects.test.ts316
-rw-r--r--apps/web/src/__tests__/setup.ts65
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx27
-rw-r--r--apps/web/src/components/delete-project-dialog.tsx2
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx85
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx61
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx63
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx51
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/lib/storage/storage-service.ts4
-rw-r--r--apps/web/src/lib/storage/types.ts1
-rw-r--r--apps/web/src/stores/project-store.ts113
-rw-r--r--apps/web/src/stores/timeline-store.ts19
-rw-r--r--apps/web/src/types/project.ts1
-rw-r--r--apps/web/src/types/timeline.ts1
17 files changed, 374 insertions, 943 deletions
diff --git a/apps/web/src/__tests__/README.md b/apps/web/src/__tests__/README.md
deleted file mode 100644
index 02173edf..00000000
--- a/apps/web/src/__tests__/README.md
+++ /dev/null
@@ -1,164 +0,0 @@
-# Test Suite: Excessive Project Creation Bug
-
-This test suite provides comprehensive testing for the excessive project creation bug reported in [GitHub Issue #182](https://github.com/OpenCut-app/OpenCut/issues/182).
-
-## Overview
-
-The bug occurs when users navigate to invalid project IDs (e.g., via browser back button or direct URL access), causing the editor page to create multiple "Untitled Project" entries due to race conditions in React useEffect dependencies.
-
-## Test Files
-
-### 1. `excessive-projects.test.ts`
-**Unit tests** that test the core logic extracted from the editor page component.
-
-**Test Coverage:**
-- ✅ Single invalid project ID handling
-- ✅ Race condition scenarios
-- ✅ React Strict Mode double execution
-- ✅ Error handling for different failure types
-- ✅ Stress testing (simulating 885 projects)
-- ✅ Bug validation and reproduction steps
-
-### 2. `editor-page-integration.test.tsx`
-**Integration tests** that test the actual React component behavior.
-
-**Test Coverage:**
-- ✅ Component rendering with invalid project IDs
-- ✅ Duplicate project prevention
-- ✅ Rapid re-render handling
-- ✅ Multiple invalid ID scenarios
-
-### 3. `setup.ts`
-Test environment setup with mocks for:
-- Next.js router
-- IndexedDB
-- OPFS (Origin Private File System)
-- Console output suppression
-
-## Running Tests
-
-### Command Line (like `cargo test`)
-
-```bash
-# Run all tests
-bun run test
-
-# Run only excessive projects tests
-bun run test:excessive-projects
-
-# Run with coverage
-bun run test:coverage
-
-# Run with UI
-bun run test:ui
-
-# Run specific test file
-bun run test src/__tests__/excessive-projects.test.ts
-
-# Run with verbose output
-VITEST_VERBOSE=1 bun run test
-```
-
-### Using the CLI Test Runner
-
-```bash
-# Run comprehensive test suite
-bun run scripts/test-excessive-projects.ts
-
-# Or use the npm script
-bun run test:excessive-projects
-```
-
-## Test Results Interpretation
-
-### Expected Behavior (Bug Reproduction)
-- ✅ **Single invalid ID** → Creates exactly 1 project
-- ✅ **Multiple invalid IDs** → Creates 1 project per unique ID
-- ✅ **Race conditions** → Prevented by handledProjectIds mechanism
-- ✅ **React Strict Mode** → No duplicate creation despite double execution
-
-### Bug Indicators
-- ❌ **Multiple projects from single invalid ID** → Race condition bug
-- ❌ **Exponential project growth** → handledProjectIds mechanism failing
-- ❌ **Projects created on every navigation** → Error handling too broad
-
-## Continuous Integration
-
-Tests are automatically run in CI/CD pipeline:
-- GitHub Actions workflow: `.github/workflows/bun-ci.yml`
-- Runs on: Ubuntu, Windows, macOS
-- Triggers: Push to main, Pull requests
-
-## Test Environment
-
-**Framework:** Vitest + Testing Library
-**Mocking:** Vi (Vitest's built-in mocking)
-**Coverage:** V8 provider
-**Environment:** jsdom (simulates browser environment)
-
-## Debugging Tests
-
-### Enable Verbose Logging
-```bash
-VITEST_VERBOSE=1 bun run test
-```
-
-### Run Single Test
-```bash
-bun run test -t "should create exactly one project"
-```
-
-### Debug Mode
-```bash
-bun run test --inspect-brk
-```
-
-## Adding New Tests
-
-1. **Unit Tests**: Add to `excessive-projects.test.ts`
- - Test core logic and edge cases
- - Mock external dependencies
-
-2. **Integration Tests**: Add to `editor-page-integration.test.tsx`
- - Test component behavior
- - Test React lifecycle interactions
-
-3. **Follow Naming Convention**:
- ```typescript
- describe('Feature Area', () => {
- it('should do something specific', async () => {
- // Test implementation
- })
- })
- ```
-
-## Performance Testing
-
-The test suite includes performance measurements:
-- Project creation timing
-- Memory usage tracking
-- Stress test scenarios
-
-Example output:
-```
-Created 100 projects in 1250ms
-Average: 12.5ms per project
-```
-
-## Bug Fix Validation
-
-After implementing the fix:
-
-1. **Run full test suite**: `bun run test`
-2. **Verify no regressions**: All existing tests should pass
-3. **Validate fix effectiveness**: Bug reproduction tests should show controlled behavior
-4. **Performance check**: Ensure no performance degradation
-
-## Contributing
-
-When adding tests:
-- ✅ Include both positive and negative test cases
-- ✅ Test edge cases and error conditions
-- ✅ Add performance considerations for stress tests
-- ✅ Update this README if adding new test categories
-- ✅ Ensure tests are deterministic and not flaky
diff --git a/apps/web/src/__tests__/editor-page-integration.test.tsx b/apps/web/src/__tests__/editor-page-integration.test.tsx
deleted file mode 100644
index 48dc9afb..00000000
--- a/apps/web/src/__tests__/editor-page-integration.test.tsx
+++ /dev/null
@@ -1,342 +0,0 @@
-/**
- * Integration Test: Editor Page Component
- *
- * Tests the actual editor page component to validate the excessive project
- * creation bug in a more realistic environment.
- */
-
-import { describe, it, expect, beforeEach, vi } from 'vitest'
-import { render, waitFor } from '@testing-library/react'
-import { act } from 'react'
-import React from 'react'
-
-// Mock the stores
-const mockInvalidIds = new Set<string>()
-
-const mockProjectStore = {
- savedProjects: [],
- activeProject: null,
- loadProject: vi.fn(),
- createNewProject: vi.fn(),
- loadAllProjects: vi.fn(),
- isInvalidProjectId: vi.fn((id: string) => mockInvalidIds.has(id)),
- markProjectIdAsInvalid: vi.fn((id: string) => mockInvalidIds.add(id)),
- clearInvalidProjectIds: vi.fn(() => mockInvalidIds.clear()),
-}
-
-const mockMediaStore = {
- loadProjectMedia: vi.fn(),
-}
-
-const mockTimelineStore = {
- loadProjectTimeline: vi.fn(),
-}
-
-vi.mock('@/stores/project-store', () => ({
- useProjectStore: (selector: any) => {
- if (typeof selector === 'function') {
- return selector(mockProjectStore)
- }
- return mockProjectStore
- },
-}))
-
-vi.mock('@/stores/media-store', () => ({
- useMediaStore: (selector: any) => {
- if (typeof selector === 'function') {
- return selector(mockMediaStore)
- }
- return mockMediaStore
- },
-}))
-
-vi.mock('@/stores/timeline-store', () => ({
- useTimelineStore: (selector: any) => {
- if (typeof selector === 'function') {
- return selector(mockTimelineStore)
- }
- return mockTimelineStore
- },
-}))
-
-// Mock other dependencies
-vi.mock('@/hooks/use-playback-controls', () => ({
- usePlaybackControls: () => {},
-}))
-
-vi.mock('@/components/editor/timeline', () => ({
- Timeline: () => <div data-testid="timeline">Timeline</div>,
-}))
-
-vi.mock('@/components/editor/preview', () => ({
- Preview: () => <div data-testid="preview">Preview</div>,
-}))
-
-vi.mock('@/components/editor/media-library', () => ({
- MediaLibrary: () => <div data-testid="media-library">Media Library</div>,
-}))
-
-// Create a simplified version of the FIXED editor page for testing
-function TestEditorPage({ params }: { params: { project_id: string } }) {
- const { project_id: projectId } = params
- const activeProject = mockProjectStore.activeProject
- const loadProject = mockProjectStore.loadProject
- const createNewProject = mockProjectStore.createNewProject
-
- const handledProjectIds = React.useRef<Set<string>>(new Set())
- const isInitializingRef = React.useRef<boolean>(false)
-
- React.useEffect(() => {
- let isCancelled = false
-
- const initProject = async () => {
- if (!projectId) {
- return
- }
-
- // Prevent duplicate initialization (FIX for race condition)
- if (isInitializingRef.current) {
- return
- }
-
- // Check if project is already loaded
- if (activeProject?.id === projectId) {
- return
- }
-
- // Check if we've already handled this project ID
- if (handledProjectIds.current.has(projectId)) {
- return
- }
-
- // Mark as initializing to prevent race conditions
- isInitializingRef.current = true
- handledProjectIds.current.add(projectId)
-
- try {
- await loadProject(projectId)
-
- // Check if component was unmounted during async operation
- if (isCancelled) {
- return
- }
-
- // Project loaded successfully
- isInitializingRef.current = false
- } catch (error) {
- // Check if component was unmounted during async operation
- if (isCancelled) {
- return
- }
-
- // More specific error handling - only create new project for actual "not found" errors
- const isProjectNotFound = error instanceof Error &&
- (error.message.includes('not found') ||
- error.message.includes('does not exist') ||
- error.message.includes('Project not found'))
-
- if (isProjectNotFound) {
- try {
- const newProjectId = await createNewProject("Untitled Project")
-
- // Check again if component was unmounted
- if (isCancelled) {
- return
- }
-
- // In real component, this would use router.replace
- window.history.replaceState({}, '', `/editor/${newProjectId}`)
- } catch (createError) {
- console.error("Failed to create new project:", createError)
- }
- } else {
- // For other errors, don't create new project
- console.error("Project loading failed with recoverable error:", error)
- // Remove from handled set so user can retry
- handledProjectIds.current.delete(projectId)
- }
-
- isInitializingRef.current = false
- }
- }
-
- initProject()
-
- // Cleanup function to cancel async operations
- return () => {
- isCancelled = true
- isInitializingRef.current = false
- }
- }, [projectId, loadProject, createNewProject]) // FIXED: Removed activeProject?.id dependency
-
- return (
- <div data-testid="editor-page">
- <div data-testid="project-id">{projectId}</div>
- <div data-testid="active-project-id">{activeProject?.id || 'none'}</div>
- </div>
- )
-}
-
-describe('Editor Page Integration Tests', () => {
- let projectCreationCount: number
-
- beforeEach(() => {
- projectCreationCount = 0
- mockProjectStore.activeProject = null
- mockProjectStore.savedProjects.length = 0
- mockInvalidIds.clear() // Reset global invalid IDs tracking
-
- // Setup mocks
- mockProjectStore.loadProject.mockRejectedValue(new Error('Project not found'))
- mockProjectStore.createNewProject.mockImplementation(async (name: string) => {
- projectCreationCount++
- console.log(`🆕 Creating project ${projectCreationCount}: ${name}`)
- const newProject = {
- id: `test-project-${projectCreationCount}`,
- name,
- createdAt: new Date(),
- }
- mockProjectStore.activeProject = newProject
- mockProjectStore.savedProjects.push(newProject)
- return newProject.id
- })
-
- // Add debug logging to track calls
- mockProjectStore.isInvalidProjectId.mockImplementation((id: string) => {
- const isInvalid = mockInvalidIds.has(id)
- console.log(`🔍 Checking if ${id} is invalid: ${isInvalid}`)
- return isInvalid
- })
-
- mockProjectStore.markProjectIdAsInvalid.mockImplementation((id: string) => {
- console.log(`❌ Marking ${id} as invalid`)
- mockInvalidIds.add(id)
- })
-
- mockMediaStore.loadProjectMedia.mockResolvedValue([])
- mockTimelineStore.loadProjectTimeline.mockResolvedValue([])
- })
-
- it('should create a project when loading an invalid project ID', async () => {
- const invalidProjectId = 'invalid-test-project'
-
- await act(async () => {
- render(<TestEditorPage params={{ project_id: invalidProjectId }} />)
- })
-
- await waitFor(() => {
- expect(projectCreationCount).toBe(1)
- })
-
- expect(mockProjectStore.createNewProject).toHaveBeenCalledWith('Untitled Project')
- expect(mockProjectStore.createNewProject).toHaveBeenCalledTimes(1)
- })
-
- it('should demonstrate the bug: random routes create new projects', async () => {
- // This test demonstrates the actual bug reported in GitHub issue #182
- // where accessing random/invalid routes creates "Untitled Project" entries
-
- const randomRoutes = [
- 'random-route-123',
- 'invalid-project-abc',
- 'non-existent-id-456',
- 'garbage-route-xyz',
- 'another-invalid-789'
- ]
-
- console.log('🐛 DEMONSTRATING BUG: Random routes creating projects...')
-
- for (const route of randomRoutes) {
- console.log(`📍 Accessing route: /editor/${route}`)
-
- await act(async () => {
- render(<TestEditorPage params={{ project_id: route }} />)
- })
-
- // Small delay to simulate real navigation timing
- await new Promise(resolve => setTimeout(resolve, 10))
- }
-
- await waitFor(() => {
- // This demonstrates the bug - each random route creates a new project
- expect(projectCreationCount).toBe(randomRoutes.length)
- })
-
- // Show the bug impact in the test output
- expect(projectCreationCount).toBe(5) // 5 random routes = 5 new projects
- expect(mockProjectStore.createNewProject).toHaveBeenCalledTimes(5)
-
- // This test proves the bug: accessing 5 random routes created 5 "Untitled Project" entries
- // In a real scenario, this is how users end up with hundreds of projects
- })
-
- it('should test global tracking functions work', async () => {
- // Test that the mock functions work correctly
- expect(mockProjectStore.isInvalidProjectId('test-route')).toBe(false)
- mockProjectStore.markProjectIdAsInvalid('test-route')
- expect(mockProjectStore.isInvalidProjectId('test-route')).toBe(true)
-
- // Verify the mock was called
- expect(mockProjectStore.markProjectIdAsInvalid).toHaveBeenCalledWith('test-route')
- expect(mockProjectStore.isInvalidProjectId).toHaveBeenCalledWith('test-route')
- })
-
- it('IMPLEMENTATION STATUS: Global tracking fix has been implemented', async () => {
- // This test documents that the global tracking fix has been implemented
- // The fix includes:
- // 1. ✅ Global invalidProjectIds tracking in project store
- // 2. ✅ isInvalidProjectId() function to check if ID is invalid
- // 3. ✅ markProjectIdAsInvalid() function to mark IDs as invalid
- // 4. ✅ Editor component updated to use global tracking
- // 5. ✅ Global check happens BEFORE local check to prevent duplicates
-
- // Verify the global tracking functions exist and work
- expect(typeof mockProjectStore.isInvalidProjectId).toBe('function')
- expect(typeof mockProjectStore.markProjectIdAsInvalid).toBe('function')
- expect(typeof mockProjectStore.clearInvalidProjectIds).toBe('function')
-
- // Test the functions work correctly
- expect(mockProjectStore.isInvalidProjectId('test-id')).toBe(false)
- mockProjectStore.markProjectIdAsInvalid('test-id')
- expect(mockProjectStore.isInvalidProjectId('test-id')).toBe(true)
-
- // The fix prevents the bug where 885 projects were created
- // by globally tracking invalid project IDs across component remounts
- expect(true).toBe(true) // Fix implemented successfully
- })
-
- it('should handle rapid re-renders without creating excessive projects', async () => {
- const invalidProjectId = 'rapid-render-test'
-
- // Simulate rapid re-renders
- const { rerender } = render(<TestEditorPage params={{ project_id: invalidProjectId }} />)
-
- for (let i = 0; i < 5; i++) {
- await act(async () => {
- rerender(<TestEditorPage params={{ project_id: invalidProjectId }} />)
- })
- }
-
- await waitFor(() => {
- expect(projectCreationCount).toBe(1)
- })
-
- expect(mockProjectStore.createNewProject).toHaveBeenCalledTimes(1)
- })
-
- it('should create separate projects for different invalid IDs', async () => {
- const invalidIds = ['test-1', 'test-2', 'test-3']
-
- for (const invalidId of invalidIds) {
- await act(async () => {
- render(<TestEditorPage params={{ project_id: invalidId }} />)
- })
- }
-
- await waitFor(() => {
- expect(projectCreationCount).toBe(invalidIds.length)
- })
-
- expect(mockProjectStore.createNewProject).toHaveBeenCalledTimes(invalidIds.length)
- })
-})
diff --git a/apps/web/src/__tests__/excessive-projects.test.ts b/apps/web/src/__tests__/excessive-projects.test.ts
deleted file mode 100644
index 54dfb6d6..00000000
--- a/apps/web/src/__tests__/excessive-projects.test.ts
+++ /dev/null
@@ -1,316 +0,0 @@
-/**
- * Test Suite: Excessive Project Creation Bug
- *
- * This test suite reproduces and validates the bug where navigating to invalid
- * project IDs creates excessive "Untitled Project" entries due to race conditions
- * in the useEffect hook dependencies.
- *
- * Bug Report: https://github.com/OpenCut-app/OpenCut/issues/182
- */
-
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import { render, waitFor } from '@testing-library/react'
-import { act } from 'react'
-
-// Mock the project store
-const mockProjects: any[] = []
-const mockActiveProject = { id: null }
-
-const mockProjectStore = {
- savedProjects: mockProjects,
- activeProject: mockActiveProject,
- loadProject: vi.fn(),
- createNewProject: vi.fn(),
- loadAllProjects: vi.fn(),
-}
-
-const mockLoadProject = vi.fn()
-const mockCreateNewProject = vi.fn()
-
-// Mock Zustand store
-vi.mock('@/stores/project-store', () => ({
- useProjectStore: (selector: any) => {
- if (typeof selector === 'function') {
- return selector(mockProjectStore)
- }
- return mockProjectStore
- },
-}))
-
-// Mock the FIXED editor page component logic (extracted for testing)
-class EditorPageLogic {
- private handledProjectIds = new Set<string>()
- private isInitializing = false
- private projectStore: any
- private router: any
-
- constructor(projectStore: any, router: any) {
- this.projectStore = projectStore
- this.router = router
- }
-
- async initProject(projectId: string, activeProject: any) {
- if (!projectId) {
- return
- }
-
- // Prevent duplicate initialization (FIX for race condition)
- if (this.isInitializing) {
- return
- }
-
- // Check if project is already loaded
- if (activeProject?.id === projectId) {
- return
- }
-
- // Check if we've already handled this project ID
- if (this.handledProjectIds.has(projectId)) {
- return
- }
-
- // Mark as initializing to prevent race conditions
- this.isInitializing = true
- this.handledProjectIds.add(projectId)
-
- try {
- await this.projectStore.loadProject(projectId)
- // Project loaded successfully
- this.isInitializing = false
- } catch (error) {
- // More specific error handling - only create new project for actual "not found" errors
- const isProjectNotFound = error instanceof Error &&
- (error.message.includes('not found') ||
- error.message.includes('does not exist') ||
- error.message.includes('Project not found'))
-
- if (isProjectNotFound) {
- try {
- const newProjectId = await this.projectStore.createNewProject("Untitled Project")
- this.router.replace(`/editor/${newProjectId}`)
- } catch (createError) {
- console.error("Failed to create new project:", createError)
- }
- } else {
- // For other errors (storage issues, corruption, etc.), don't create new project
- console.error("Project loading failed with recoverable error:", error)
- // Remove from handled set so user can retry
- this.handledProjectIds.delete(projectId)
- }
-
- this.isInitializing = false
- }
- }
-
- // Simulate the useEffect behavior with dependencies (FIXED - no activeProject dependency)
- async simulateUseEffect(projectId: string, activeProject: any) {
- // This simulates how React useEffect would trigger with FIXED dependencies
- await this.initProject(projectId, activeProject)
- }
-
- reset() {
- this.handledProjectIds.clear()
- this.isInitializing = false
- }
-}
-
-describe('Excessive Project Creation Bug', () => {
- let editorLogic: EditorPageLogic
- let projectCreationCount: number
-
- beforeEach(() => {
- // Reset state
- mockProjects.length = 0
- mockActiveProject.id = null
- projectCreationCount = 0
-
- // Setup mocks
- mockLoadProject.mockRejectedValue(new Error('Project not found'))
- mockCreateNewProject.mockImplementation(async (name: string) => {
- projectCreationCount++
- const newProject = {
- id: `generated-id-${projectCreationCount}`,
- name,
- createdAt: new Date(),
- }
- mockProjects.push(newProject)
- mockActiveProject.id = newProject.id
- return newProject.id
- })
-
- mockProjectStore.loadProject = mockLoadProject
- mockProjectStore.createNewProject = mockCreateNewProject
-
- editorLogic = new EditorPageLogic(mockProjectStore, global.mockRouter)
- })
-
- afterEach(() => {
- editorLogic.reset()
- vi.clearAllMocks()
- })
-
- describe('Single Invalid Project ID', () => {
- it('should create exactly one project for a single invalid ID', async () => {
- const invalidProjectId = 'invalid-project-123'
-
- await editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
-
- expect(projectCreationCount).toBe(1)
- expect(mockCreateNewProject).toHaveBeenCalledTimes(1)
- expect(mockCreateNewProject).toHaveBeenCalledWith('Untitled Project')
- expect(global.mockRouter.replace).toHaveBeenCalledWith('/editor/generated-id-1')
- })
-
- it('should not create duplicate projects for the same invalid ID', async () => {
- const invalidProjectId = 'invalid-project-123'
-
- // First call
- await editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
-
- // Second call with same ID (should be prevented by handledProjectIds)
- await editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
-
- expect(projectCreationCount).toBe(1)
- expect(mockCreateNewProject).toHaveBeenCalledTimes(1)
- })
- })
-
- describe('Race Condition Scenarios', () => {
- it('should reproduce the race condition when activeProject changes', async () => {
- const invalidProjectId = 'invalid-project-race'
-
- // Simulate the race condition sequence:
- // 1. useEffect triggers with invalid project ID
- await editorLogic.simulateUseEffect(invalidProjectId, { id: null })
-
- // 2. activeProject changes (this would trigger useEffect again in React)
- const newActiveProject = { id: 'generated-id-1' }
-
- // 3. useEffect triggers again with same projectId but different activeProject
- await editorLogic.simulateUseEffect(invalidProjectId, newActiveProject)
-
- // Should still only create one project due to handledProjectIds
- expect(projectCreationCount).toBe(1)
- })
-
- it('should demonstrate the bug with rapid navigation', async () => {
- const invalidIds = [
- 'rapid-test-1',
- 'rapid-test-2',
- 'rapid-test-3',
- 'rapid-test-4',
- 'rapid-test-5'
- ]
-
- // Simulate rapid navigation to multiple invalid IDs
- for (const invalidId of invalidIds) {
- await editorLogic.simulateUseEffect(invalidId, mockActiveProject)
- }
-
- // Each invalid ID should create exactly one project
- expect(projectCreationCount).toBe(invalidIds.length)
- expect(mockCreateNewProject).toHaveBeenCalledTimes(invalidIds.length)
- })
-
- it('should simulate React Strict Mode double execution', async () => {
- const invalidProjectId = 'strict-mode-test'
-
- // Simulate React Strict Mode double execution
- const promises = [
- editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject),
- editorLogic.simulateUseEffect(invalidProjectId, mockActiveProject)
- ]
-
- await Promise.all(promises)
-
- // Should only create one project despite double execution
- // (This test validates that handledProjectIds prevents duplicates)
- expect(projectCreationCount).toBe(1)
- })
- })
-
- describe('Error Handling Scenarios', () => {
- it('should only create projects for "not found" errors, not other failures', async () => {
- const scenarios = [
- { id: 'storage-error', error: new Error('Storage quota exceeded'), shouldCreateProject: false },
- { id: 'network-error', error: new Error('Network timeout'), shouldCreateProject: false },
- { id: 'corruption-error', error: new Error('File corrupted'), shouldCreateProject: false },
- { id: 'permission-error', error: new Error('Permission denied'), shouldCreateProject: false },
- { id: 'not-found-error', error: new Error('Project not found'), shouldCreateProject: true },
- { id: 'does-not-exist-error', error: new Error('Project does not exist'), shouldCreateProject: true }
- ]
-
- let expectedProjectCount = 0
- for (const scenario of scenarios) {
- mockLoadProject.mockRejectedValueOnce(scenario.error)
- await editorLogic.simulateUseEffect(scenario.id, mockActiveProject)
- if (scenario.shouldCreateProject) {
- expectedProjectCount++
- }
- }
-
- // Only "not found" errors should create new projects
- expect(projectCreationCount).toBe(expectedProjectCount)
- expect(expectedProjectCount).toBe(2) // Only the 2 "not found" scenarios
- })
- })
-
- describe('Stress Test - Excessive Project Creation', () => {
- it('should demonstrate how 885 projects could be created', async () => {
- const numberOfInvalidRequests = 50 // Simulate many invalid requests over time
-
- // Simulate accumulated invalid project requests over development sessions
- for (let i = 0; i < numberOfInvalidRequests; i++) {
- const invalidId = `stress-test-${i}-${Date.now()}`
- await editorLogic.simulateUseEffect(invalidId, mockActiveProject)
-
- // Reset handledProjectIds occasionally to simulate component remounting
- if (i % 10 === 0) {
- editorLogic.reset()
- }
- }
-
- expect(projectCreationCount).toBe(numberOfInvalidRequests)
- expect(mockProjects).toHaveLength(numberOfInvalidRequests)
-
- // Verify all projects are "Untitled Project"
- const untitledProjects = mockProjects.filter(p => p.name === 'Untitled Project')
- expect(untitledProjects).toHaveLength(numberOfInvalidRequests)
- })
- })
-
- describe('Bug Validation', () => {
- it('should confirm the exact bug reproduction steps', async () => {
- // Step 1: Navigate to invalid project ID (like browser back button)
- const invalidId = 'back-button-navigation-test'
-
- // Step 2: Project load fails, creates new project
- await editorLogic.simulateUseEffect(invalidId, mockActiveProject)
-
- expect(projectCreationCount).toBe(1)
- expect(mockProjects[0].name).toBe('Untitled Project')
- expect(global.mockRouter.replace).toHaveBeenCalledWith('/editor/generated-id-1')
-
- // Step 3: Verify the project was added to storage
- expect(mockProjects).toHaveLength(1)
- })
-
- it('should measure the impact of the bug', async () => {
- const startTime = Date.now()
- const testIterations = 100
-
- for (let i = 0; i < testIterations; i++) {
- await editorLogic.simulateUseEffect(`test-${i}`, mockActiveProject)
- }
-
- const endTime = Date.now()
- const duration = endTime - startTime
-
- expect(projectCreationCount).toBe(testIterations)
-
- // Log performance impact
- console.log(`Created ${projectCreationCount} projects in ${duration}ms`)
- console.log(`Average: ${duration / testIterations}ms per project`)
- })
- })
-})
diff --git a/apps/web/src/__tests__/setup.ts b/apps/web/src/__tests__/setup.ts
deleted file mode 100644
index 82176a41..00000000
--- a/apps/web/src/__tests__/setup.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import '@testing-library/jest-dom'
-
-// Mock Next.js router
-const mockRouter = {
- push: vi.fn(),
- replace: vi.fn(),
- back: vi.fn(),
- forward: vi.fn(),
- refresh: vi.fn(),
- prefetch: vi.fn(),
-}
-
-vi.mock('next/navigation', () => ({
- useRouter: () => mockRouter,
- usePathname: () => '/test',
- useSearchParams: () => new URLSearchParams(),
-}))
-
-// Mock IndexedDB for storage tests
-const mockIndexedDB = {
- open: vi.fn(),
- deleteDatabase: vi.fn(),
-}
-
-Object.defineProperty(window, 'indexedDB', {
- value: mockIndexedDB,
- writable: true,
-})
-
-// Mock OPFS (Origin Private File System)
-Object.defineProperty(navigator, 'storage', {
- value: {
- getDirectory: vi.fn(),
- },
- writable: true,
-})
-
-// Global test utilities
-global.mockRouter = mockRouter
-
-// Suppress console logs during tests unless explicitly needed
-const originalConsoleLog = console.log
-const originalConsoleError = console.error
-const originalConsoleWarn = console.warn
-
-beforeEach(() => {
- // Reset all mocks before each test
- vi.clearAllMocks()
-
- // Suppress console output unless VITEST_VERBOSE is set
- if (!process.env.VITEST_VERBOSE) {
- console.log = vi.fn()
- console.error = vi.fn()
- console.warn = vi.fn()
- }
-})
-
-afterEach(() => {
- // Restore console if it was mocked
- if (!process.env.VITEST_VERBOSE) {
- console.log = originalConsoleLog
- console.error = originalConsoleError
- console.warn = originalConsoleWarn
- }
-})
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 3378d11d..5e294fe7 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -65,7 +65,7 @@ export default function Editor() {
return;
}
- // Check global invalid tracking FIRST (most important for preventing duplicates)
+ // Check global invalid tracking first (most important for preventing duplicates)
if (isInvalidProjectId(projectId)) {
return;
}
@@ -96,10 +96,11 @@ export default function Editor() {
}
// More specific error handling - only create new project for actual "not found" errors
- const isProjectNotFound = error instanceof Error &&
- (error.message.includes('not found') ||
- error.message.includes('does not exist') ||
- error.message.includes('Project not found'));
+ const isProjectNotFound =
+ error instanceof Error &&
+ (error.message.includes("not found") ||
+ error.message.includes("does not exist") ||
+ error.message.includes("Project not found"));
if (isProjectNotFound) {
// Mark this project ID as invalid globally BEFORE creating project
@@ -118,8 +119,11 @@ export default function Editor() {
console.error("Failed to create new project:", createError);
}
} else {
- // For other errors (storage issues, corruption, etc.), log but don't create new project
- console.error("Project loading failed with recoverable error:", error);
+ // For other errors (storage issues, corruption, etc.), don't create new project
+ console.error(
+ "Project loading failed with recoverable error:",
+ error
+ );
// Remove from handled set so user can retry
handledProjectIds.current.delete(projectId);
}
@@ -135,7 +139,14 @@ export default function Editor() {
isCancelled = true;
isInitializingRef.current = false;
};
- }, [projectId, loadProject, createNewProject, router]); // Removed activeProject?.id dependency
+ }, [
+ projectId,
+ loadProject,
+ createNewProject,
+ router,
+ isInvalidProjectId,
+ markProjectIdAsInvalid,
+ ]);
return (
<EditorProvider>
diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx
index e2b32dda..e8f8703c 100644
--- a/apps/web/src/components/delete-project-dialog.tsx
+++ b/apps/web/src/components/delete-project-dialog.tsx
@@ -49,7 +49,7 @@ export function DeleteProjectDialog({
</DialogHeader>
<DialogFooter>
<Button
- variant="outline"
+ variant="text"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
index a673f995..372a31c5 100644
--- a/apps/web/src/components/editor/audio-waveform.tsx
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -19,22 +19,21 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
useEffect(() => {
let mounted = true;
-
+ let ws = wavesurfer.current;
+
const initWaveSurfer = async () => {
if (!waveformRef.current || !audioUrl) return;
try {
- // Clean up any existing instance
- if (wavesurfer.current) {
- try {
- wavesurfer.current.destroy();
- } catch (e) {
- // Silently ignore destroy errors
- }
+ // Clear any existing instance safely
+ if (ws) {
+ // Instead of immediately destroying, just set to null
+ // We'll destroy it outside this function
wavesurfer.current = null;
}
- wavesurfer.current = WaveSurfer.create({
+ // Create a fresh instance
+ const newWaveSurfer = WaveSurfer.create({
container: waveformRef.current,
waveColor: "rgba(255, 255, 255, 0.6)",
progressColor: "rgba(255, 255, 255, 0.9)",
@@ -46,15 +45,28 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
interact: false,
});
+ // Assign to ref only if component is still mounted
+ if (mounted) {
+ wavesurfer.current = newWaveSurfer;
+ } else {
+ // Component unmounted during initialization, clean up
+ try {
+ newWaveSurfer.destroy();
+ } catch (e) {
+ // Ignore destroy errors
+ }
+ return;
+ }
+
// Event listeners
- wavesurfer.current.on("ready", () => {
+ newWaveSurfer.on("ready", () => {
if (mounted) {
setIsLoading(false);
setError(false);
}
});
- wavesurfer.current.on("error", (err) => {
+ newWaveSurfer.on("error", (err) => {
console.error("WaveSurfer error:", err);
if (mounted) {
setError(true);
@@ -62,7 +74,7 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
}
});
- await wavesurfer.current.load(audioUrl);
+ await newWaveSurfer.load(audioUrl);
} catch (err) {
console.error("Failed to initialize WaveSurfer:", err);
if (mounted) {
@@ -72,17 +84,50 @@ const AudioWaveform: React.FC<AudioWaveformProps> = ({
}
};
- initWaveSurfer();
-
- return () => {
- mounted = false;
- if (wavesurfer.current) {
+ // First safely destroy previous instance if it exists
+ if (ws) {
+ // Use this pattern to safely destroy the previous instance
+ const wsToDestroy = ws;
+ // Detach from ref immediately
+ wavesurfer.current = null;
+
+ // Wait a tick to destroy so any pending operations can complete
+ requestAnimationFrame(() => {
try {
- wavesurfer.current.destroy();
+ wsToDestroy.destroy();
} catch (e) {
- // Silently ignore destroy errors
+ // Ignore errors during destroy
+ }
+ // Only initialize new instance after destroying the old one
+ if (mounted) {
+ initWaveSurfer();
}
- wavesurfer.current = null;
+ });
+ } else {
+ // No previous instance to clean up, initialize directly
+ initWaveSurfer();
+ }
+
+ return () => {
+ // Mark component as unmounted
+ mounted = false;
+
+ // Store reference to current wavesurfer instance
+ const wsToDestroy = wavesurfer.current;
+
+ // Immediately clear the ref to prevent accessing it after unmount
+ wavesurfer.current = null;
+
+ // If we have an instance to clean up, do it safely
+ if (wsToDestroy) {
+ // Delay destruction to avoid race conditions
+ requestAnimationFrame(() => {
+ try {
+ wsToDestroy.destroy();
+ } catch (e) {
+ // Ignore destroy errors - they're expected
+ }
+ });
}
};
}, [audioUrl, height]);
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 4cc35bb5..55d52bed 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -234,6 +234,7 @@ export function PreviewPanel() {
tracks.forEach((track) => {
track.elements.forEach((element) => {
+ if (element.hidden) return;
const elementStart = element.startTime;
const elementEnd =
element.startTime +
@@ -826,6 +827,18 @@ function PreviewToolbar({
setCanvasSizeToOriginal(aspectRatio);
};
+ const totalDuration = getTotalDuration();
+
+ const skipBackward = () => {
+ const newTime = Math.max(0, currentTime - 1);
+ setCurrentTime(newTime);
+ };
+
+ const skipForward = () => {
+ const newTime = Math.min(totalDuration, currentTime + 1);
+ setCurrentTime(newTime);
+ };
+
if (isExpanded) {
return (
<FullscreenToolbar
@@ -871,19 +884,41 @@ function PreviewToolbar({
</span>
</p>
</div>
- <Button
- variant="text"
- size="icon"
- onClick={toggle}
- disabled={!hasAnyElements}
- className="h-auto p-0"
- >
- {isPlaying ? (
- <Pause className="h-3 w-3" />
- ) : (
- <Play className="h-3 w-3" />
- )}
- </Button>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="text"
+ size="icon"
+ onClick={skipBackward}
+ disabled={!hasAnyElements}
+ className="h-auto p-0 text-white hover:text-white/80"
+ title="Skip backward 1s"
+ >
+ <SkipBack className="h-3 w-3" />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ disabled={!hasAnyElements}
+ className="h-auto p-0 text-white hover:text-white/80"
+ >
+ {isPlaying ? (
+ <Pause className="h-3 w-3" />
+ ) : (
+ <Play className="h-3 w-3" />
+ )}
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={skipForward}
+ disabled={!hasAnyElements}
+ className="h-auto p-0 text-white hover:text-white/80"
+ title="Skip forward 1s"
+ >
+ <SkipForward className="h-3 w-3" />
+ </Button>
+ </div>
<div className="flex items-center gap-3">
<BackgroundSettings />
<DropdownMenu>
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index 078f1258..9833adad 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -19,6 +19,7 @@ import {
Link,
ZoomIn,
ZoomOut,
+ Bookmark,
} from "lucide-react";
import {
Tooltip,
@@ -651,6 +652,30 @@ export function Timeline() {
);
}).filter(Boolean);
})()}
+
+ {/* Bookmark markers */}
+ {(() => {
+ const { activeProject } = useProjectStore.getState();
+ if (!activeProject?.bookmarks?.length) return null;
+
+ return activeProject.bookmarks.map((bookmarkTime, i) => (
+ <div
+ key={`bookmark-${i}`}
+ className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
+ style={{
+ left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ usePlaybackStore.getState().seek(bookmarkTime);
+ }}
+ >
+ <div className="absolute top-[-1px] left-[-5px] text-primary">
+ <Bookmark className="h-3 w-3 fill-primary" />
+ </div>
+ </div>
+ ));
+ })()}
</div>
</ScrollArea>
</div>
@@ -773,6 +798,23 @@ export function Timeline() {
<ContextMenuItem onClick={(e) => e.stopPropagation()}>
Track settings (soon)
</ContextMenuItem>
+ {activeProject?.bookmarks?.length && activeProject.bookmarks.length > 0 && (
+ <>
+ <ContextMenuItem disabled>Bookmarks</ContextMenuItem>
+ {activeProject.bookmarks.map((bookmarkTime, i) => (
+ <ContextMenuItem
+ key={`bookmark-menu-${i}`}
+ onClick={(e) => {
+ e.stopPropagation();
+ seek(bookmarkTime);
+ }}
+ >
+ <Bookmark className="h-3 w-3 mr-2 inline-block" />
+ {bookmarkTime.toFixed(1)}s
+ </ContextMenuItem>
+ ))}
+ </>
+ )}
</ContextMenuContent>
</ContextMenu>
))}
@@ -828,6 +870,7 @@ function TimelineToolbar({
toggleRippleEditing,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
+ const { toggleBookmark, isBookmarked } = useProjectStore();
// Action handlers
const handleSplitSelected = () => {
@@ -957,6 +1000,13 @@ function TimelineToolbar({
const handleZoomSliderChange = (values: number[]) => {
setZoomLevel(values[0]);
};
+
+ const handleToggleBookmark = async () => {
+ await toggleBookmark(currentTime);
+ };
+
+ // Check if the current time is bookmarked
+ const currentBookmarked = isBookmarked(currentTime);
return (
<div className="border-b flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-1 w-full">
@@ -1088,6 +1138,17 @@ function TimelineToolbar({
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleToggleBookmark}>
+ <Bookmark className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`} />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {currentBookmarked ? "Remove bookmark" : "Add bookmark"}
+ </TooltipContent>
+ </Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
@@ -1121,6 +1182,8 @@ function TimelineToolbar({
</TooltipContent>
</Tooltip>
</TooltipProvider>
+
+ <div className="h-6 w-px bg-border mx-1" />
<div className="flex items-center gap-1">
<Button variant="text" size="icon" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx
index f581bbf0..96859c38 100644
--- a/apps/web/src/components/editor/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-element.tsx
@@ -13,6 +13,10 @@ import {
Type,
Copy,
RefreshCw,
+ EyeOff,
+ Eye,
+ Volume2,
+ VolumeX,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
@@ -66,11 +70,18 @@ export function TimelineElement({
addElementToTrack,
replaceElementMedia,
rippleEditingEnabled,
+ toggleElementHidden,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false);
+ const mediaItem =
+ element.type === "media"
+ ? mediaItems.find((item) => item.id === element.mediaId)
+ : null;
+ const isAudio = mediaItem?.type === "audio";
+
const {
resizing,
isResizing,
@@ -141,6 +152,11 @@ export function TimelineElement({
}
};
+ const handleToggleElementHidden = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ toggleElementHidden(track.id, element.id);
+ };
+
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
@@ -332,7 +348,7 @@ export function TimelineElement({
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
- }`}
+ } ${element.hidden ? "opacity-50" : ""}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
@@ -343,6 +359,16 @@ export function TimelineElement({
{renderElementContent()}
</div>
+ {element.hidden && (
+ <div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
+ {isAudio ? (
+ <VolumeX className="h-6 w-6 text-white" />
+ ) : (
+ <EyeOff className="h-6 w-6 text-white" />
+ )}
+ </div>
+ )}
+
{isSelected && (
<>
<div
@@ -363,6 +389,29 @@ export function TimelineElement({
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
+ <ContextMenuItem onClick={handleToggleElementHidden}>
+ {isAudio ? (
+ element.hidden ? (
+ <Volume2 className="h-4 w-4 mr-2" />
+ ) : (
+ <VolumeX className="h-4 w-4 mr-2" />
+ )
+ ) : element.hidden ? (
+ <Eye className="h-4 w-4 mr-2" />
+ ) : (
+ <EyeOff className="h-4 w-4 mr-2" />
+ )}
+ <span>
+ {isAudio
+ ? element.hidden
+ ? "Unmute"
+ : "Mute"
+ : element.hidden
+ ? "Show"
+ : "Hide"}{" "}
+ {element.type === "text" ? "text" : "clip"}
+ </span>
+ </ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 5902af65..3a1a511f 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -15,7 +15,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
+ "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
text: "bg-transparent p-0 rounded-none hover:text-muted-foreground", // Instead of ghost (matches app better)
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 0037dbdb..2ff63ac7 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -63,6 +63,8 @@ class StorageService {
backgroundColor: project.backgroundColor,
backgroundType: project.backgroundType,
blurIntensity: project.blurIntensity,
+ bookmarks: project.bookmarks,
+ fps: project.fps,
};
await this.projectsAdapter.set(project.id, serializedProject);
@@ -83,6 +85,8 @@ class StorageService {
backgroundColor: serializedProject.backgroundColor,
backgroundType: serializedProject.backgroundType,
blurIntensity: serializedProject.blurIntensity,
+ bookmarks: serializedProject.bookmarks,
+ fps: serializedProject.fps,
};
}
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
index b662c164..0cfafd47 100644
--- a/apps/web/src/lib/storage/types.ts
+++ b/apps/web/src/lib/storage/types.ts
@@ -37,6 +37,7 @@ export interface StorageConfig {
export type SerializedProject = Omit<TProject, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
+ bookmarks?: number[];
};
// Extend FileSystemDirectoryHandle with missing async iterator methods
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 82f449de..bfabba1f 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -29,6 +29,11 @@ interface ProjectStore {
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
+ // Bookmark methods
+ toggleBookmark: (time: number) => Promise<void>;
+ isBookmarked: (time: number) => boolean;
+ removeBookmark: (time: number) => Promise<void>;
+
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string
@@ -47,6 +52,97 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
isInitialized: false,
invalidProjectIds: new Set<string>(),
+ // Implementation of bookmark methods
+ toggleBookmark: async (time: number) => {
+ const { activeProject } = get();
+ if (!activeProject) return;
+
+ // Round time to the nearest frame
+ const fps = activeProject.fps || 30;
+ const frameTime = Math.round(time * fps) / fps;
+
+ const bookmarks = activeProject.bookmarks || [];
+ let updatedBookmarks: number[];
+
+ // Check if already bookmarked
+ const bookmarkIndex = bookmarks.findIndex(
+ (bookmark) => Math.abs(bookmark - frameTime) < 0.001
+ );
+
+ if (bookmarkIndex !== -1) {
+ // Remove bookmark
+ updatedBookmarks = bookmarks.filter((_, i) => i !== bookmarkIndex);
+ } else {
+ // Add bookmark
+ updatedBookmarks = [...bookmarks, frameTime].sort((a, b) => a - b);
+ }
+
+ const updatedProject = {
+ ...activeProject,
+ bookmarks: updatedBookmarks,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project bookmarks:", error);
+ toast.error("Failed to update bookmarks", {
+ description: "Please try again",
+ });
+ }
+ },
+
+ isBookmarked: (time: number) => {
+ const { activeProject } = get();
+ if (!activeProject || !activeProject.bookmarks) return false;
+
+ // Round time to the nearest frame
+ const fps = activeProject.fps || 30;
+ const frameTime = Math.round(time * fps) / fps;
+
+ return activeProject.bookmarks.some(
+ (bookmark) => Math.abs(bookmark - frameTime) < 0.001
+ );
+ },
+
+ removeBookmark: async (time: number) => {
+ const { activeProject } = get();
+ if (!activeProject || !activeProject.bookmarks) return;
+
+ // Round time to the nearest frame
+ const fps = activeProject.fps || 30;
+ const frameTime = Math.round(time * fps) / fps;
+
+ const updatedBookmarks = activeProject.bookmarks.filter(
+ (bookmark) => Math.abs(bookmark - frameTime) >= 0.001
+ );
+
+ if (updatedBookmarks.length === activeProject.bookmarks.length) {
+ // No bookmark found to remove
+ return;
+ }
+
+ const updatedProject = {
+ ...activeProject,
+ bookmarks: updatedBookmarks,
+ updatedAt: new Date(),
+ };
+
+ try {
+ await storageService.saveProject(updatedProject);
+ set({ activeProject: updatedProject });
+ await get().loadAllProjects(); // Refresh the list
+ } catch (error) {
+ console.error("Failed to update project bookmarks:", error);
+ toast.error("Failed to remove bookmark", {
+ description: "Please try again",
+ });
+ }
+ },
+
createNewProject: async (name: string) => {
const newProject: TProject = {
id: generateUUID(),
@@ -57,27 +153,17 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
backgroundColor: "#000000",
backgroundType: "color",
blurIntensity: 8,
+ bookmarks: [],
};
- // DEBUG: Log project creation
- console.log("🆕 Creating new project:", {
- id: newProject.id,
- name: newProject.name,
- timestamp: new Date().toISOString()
- });
-
set({ activeProject: newProject });
- console.log("📝 Set activeProject to new project");
try {
await storageService.saveProject(newProject);
- console.log("💾 Project saved to storage");
// Reload all projects to update the list
await get().loadAllProjects();
- console.log("🔄 All projects reloaded");
return newProject.id;
} catch (error) {
- console.error("❌ Failed to save new project:", error);
toast.error("Failed to save new project");
throw error;
}
@@ -384,7 +470,10 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
markProjectIdAsInvalid: (id: string) => {
set((state) => ({
- invalidProjectIds: new Set([...(state.invalidProjectIds || new Set()), id])
+ invalidProjectIds: new Set([
+ ...(state.invalidProjectIds || new Set()),
+ id,
+ ]),
}));
},
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index f3497be9..0f441176 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -126,6 +126,7 @@ interface TimelineStore {
pushHistory?: boolean
) => void;
toggleTrackMute: (trackId: string) => void;
+ toggleElementHidden: (trackId: string, elementId: string) => void;
// Split operations for elements
splitElement: (
@@ -868,6 +869,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
);
},
+ toggleElementHidden: (trackId, elementId) => {
+ get().pushHistory();
+ updateTracksAndSave(
+ get()._tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, hidden: !element.hidden }
+ : element
+ ),
+ }
+ : track
+ )
+ );
+ },
+
updateTextElement: (trackId, elementId, updates) => {
get().pushHistory();
updateTracksAndSave(
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index 58d37b03..f52cba73 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -9,4 +9,5 @@ export interface TProject {
backgroundType?: "color" | "blur";
blurIntensity?: number; // in pixels (4, 8, 18)
fps?: number;
+ bookmarks?: number[];
}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index ef20c642..cd17b7dd 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -11,6 +11,7 @@ interface BaseTimelineElement {
startTime: number;
trimStart: number;
trimEnd: number;
+ hidden?: boolean;
}
// Media element that references MediaStore