diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..95a29c0 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,100 @@ +# Daggerheart Project Organization Rules + +## Core Principle: Component-Based Co-Location +Organize files by **domain/component** rather than by **file type**. Keep related functionality together. + +## Directory Structure Rules + +### 1. Component Organization +``` +src/ +├── state/ # Global state management only +│ ├── GameStateContext.jsx +│ ├── useGameState.js +│ ├── state.json +│ └── usePersistentState.js +├── components/ # All UI components + their utilities +│ ├── Layout/ # App layout + layout-specific utilities +│ │ ├── Layout.jsx +│ │ ├── Layout.css +│ │ ├── usePreventPullToRefresh.js # Layout-specific +│ │ ├── TopBar/ +│ │ ├── BottomBar/ +│ │ │ └── logo.png # Co-located asset +│ │ ├── LeftPanel/ +│ │ ├── RightPanel/ +│ │ │ └── useRightPanelSync.js # Panel-specific +│ │ └── Drawer/ +│ │ └── useSwipeDrawer.js # Drawer-specific +│ ├── Browser/ # Database browsing + browser utilities +│ │ └── useBrowser.js # Browser-specific +│ ├── GameBoard/ # Game interface + game utilities +│ │ └── ids.js # Used by game entities +│ ├── Adversaries/ # Adversary domain (data + logic + utilities) +│ │ ├── adversaries.json # Data co-located with logic +│ │ ├── adversaries.js # Actions +│ │ └── useAdversaryHandlers.js # Adversary-specific +│ ├── Environments/ # Environment domain +│ │ ├── environments.json +│ │ ├── environments.js +│ │ └── [environment utilities] +│ ├── Countdowns/ # Countdown domain +│ │ ├── countdowns.js +│ │ ├── countdownEngine.js +│ │ └── countdownEngine.test.js +│ ├── Fear/ # Fear component + utilities +│ └── PWAInstallPrompt/ # PWA component +└── styles/ # Global styles (to be migrated) +``` + +### 2. Co-Location Rules + +#### A. Utilities Go With Their Users +- **useRightPanelSync** → `RightPanel/` (used only by RightPanel) +- **useSwipeDrawer** → `Drawer/` (used only by Drawer) +- **useBrowser** → `Browser/` (used only by Browser) +- **useAdversaryHandlers** → `Adversaries/` (adversary-specific) + +#### B. Data Goes With Logic +- **adversaries.json** → `Adversaries/` (with adversaries.js) +- **environments.json** → `Environments/` (with environments.js) +- **logo.png** → `BottomBar/` (used only by help button) + +#### C. Domain-Specific Utilities +- **ids.js** → `GameBoard/` (generates IDs for game entities) +- **countdownEngine.js** → `Countdowns/` (countdown-specific logic) + +### 3. What Goes in `state/` vs `components/` +- **`state/`**: Only truly global state management (GameStateContext, useGameState, state.json, usePersistentState) +- **`components/`**: Everything else, organized by domain/component + +## Migration Guidelines + +### When Adding New Files: +1. **Ask**: "Which component/domain uses this?" +2. **Place**: In that component's directory +3. **Exception**: Only put in `state/` if used by multiple domains + +### When Moving Files: +1. **Use VSCode refactoring tools** (drag & drop, F2 rename) - they auto-update imports +2. **Update import paths** if manual moves are needed +3. **Verify** all imports are working + +### File Naming Conventions: +- **Components**: `ComponentName.jsx` + `ComponentName.css` +- **Utilities**: `useUtilityName.js` or `utilityName.js` +- **Data**: `domain.json` (co-located with domain logic) +- **Assets**: Co-located with the component that uses them + +## Benefits of This Structure: +- ✅ **Intuitive**: "Need adversary stuff? Go to Adversaries/" +- ✅ **Co-located**: Everything related is together +- ✅ **Scalable**: Easy to add new domains/components +- ✅ **Maintainable**: Changes stay within domain boundaries +- ✅ **Non-coder friendly**: Logical organization + +## Anti-Patterns to Avoid: +- ❌ Grouping by file type (`hooks/`, `utils/`, `data/`) +- ❌ Deep nesting (keep it flat under `components/`) +- ❌ Global directories for domain-specific code +- ❌ Scattered related functionality diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9ba35dc --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended" + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["react", "react-hooks"], + "settings": { + "react": { "version": "detect" } + }, + "rules": { + "react/prop-types": "off", + "no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }] + } +} + + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index e5e6505..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Deploy to GitHub Pages - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v3 - if: github.ref == 'refs/heads/main' - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./dist diff --git a/.gitignore b/.gitignore index 475bdf8..e7c3ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,14 @@ jspm_packages/ # TernJS port file .tern-port + +# Firebase and API keys +firebase-config.js +firebase-config.json +src/firebase/config.js +src/firebase/sessionService.js +**/firebase/** +**/*firebase* +**/*api-key* +**/*secret* +**/*credential* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..157225a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "singleQuote": true, + "semi": false, + "printWidth": 100, + "trailingComma": "es5" +} + + diff --git a/Darrington-Press-CGL.pdf b/Darrington-Press-CGL.pdf new file mode 100644 index 0000000..0843727 Binary files /dev/null and b/Darrington-Press-CGL.pdf differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d49fe10 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Splinter714 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d534f59..a0ba063 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,32 @@ -# 🎲 Daggerheart GM Dashboard +# Daggerheart GM Dashboard -A static web application for Game Masters to manage their Daggerheart RPG sessions with local state management. +A modern, mobile-optimized web application for Game Masters to manage their Daggerheart RPG sessions with intelligent state management and intuitive UX. + +## Features + +### Core Game Management +- **Smart Countdown System**: Create countdowns up to 100 pips with intelligent 5-pip grouping for easy reading +- **Adversary Tracking**: Complete HP/stress management with damage input system and threshold calculations +- **Environment Management**: Track location effects and environmental aspects +- **Fear & Hope Tracking**: Simple fear/hope countdowns with advancement triggers + +### Mobile-First Design +- **Swipeable Drawers**: Smooth mobile navigation with swipe-to-close gestures +- **Touch-Optimized**: All interactions work perfectly on mobile devices +- **Responsive Layout**: Adapts seamlessly from phone to desktop +- **Pull-to-Refresh Prevention**: Smart prevention of accidental page refreshes + +### Modern UX +- **Intuitive Icons**: Pencil for edit mode, trashcan for delete, wrench for tools +- **Smart Grouping**: Countdown pips automatically group in sets of 5 for readability +- **Persistent State**: Game state and filters save automatically across sessions +- **Drag & Drop**: Reorder adversaries and environments with touch-friendly drag controls + +### Database Integration +- **Full Database Browser**: Browse complete adversary and environment databases +- **Smart Filtering**: Persistent filters with search and tier/type filtering +- **Quick Add**: Add items directly from database to your game +- **Custom Creation**: Create custom adversaries and environments ## Quick Start @@ -16,37 +42,126 @@ A static web application for Game Masters to manage their Daggerheart RPG sessio 3. **Access the dashboard:** - **GM Dashboard**: `http://localhost:5173` + - **Live Demo**: [https://splinter714.github.io/Daggerheart/](https://splinter714.github.io/Daggerheart/) 4. **Build for production:** ```bash npm run build ``` -## Features +5. **Deploy:** + ```bash + ./deploy.sh + ``` + +## Architecture + +### Project Structure +``` +src/ +├── components/ +│ ├── browser/ # Database browsing components +│ ├── cards/ # Card display components +│ ├── controls/ # UI controls (buttons, badges) +│ ├── countdown/ # Countdown management +│ ├── editor/ # Creation/editing forms +│ ├── game/ # Game board sections +│ ├── panels/ # Main layout panels +│ └── ui/ # Reusable UI components +├── hooks/ # Custom React hooks +├── state/ # State management +└── utils/ # Utility functions +``` + +### Tech Stack +- **React 18** with modern hooks and concurrent features +- **Vite** for fast development and optimized builds +- **Lucide React** for consistent iconography +- **CSS Variables** for theming and customization +- **localStorage** for persistent state management + +## Mobile Experience + +The app is designed mobile-first with several key optimizations: + +- **Swipe Navigation**: Swipe drawers open/close naturally +- **Touch Gestures**: All interactions optimized for touch +- **Responsive Design**: Adapts to any screen size +- **Performance**: Optimized for mobile devices +- **Offline Ready**: Works without internet connection + +## Usage Guide -- **Fear Management**: Track and display fear levels -- **Countdown Tracks**: Create and manage multiple countdown timers -- **Adversary HP**: Track enemy health with damage/healing controls -- **Environment Elements**: Manage location aspects and effects -- **Damage Thresholds**: Calculate damage based on Daggerheart rules -- **Auto-save**: State automatically saves to browser localStorage -- **Mobile Responsive**: Works on all device sizes -- **Database Browser**: Full adversary and environment database +### Countdown Management +- **Create**: Use the "+" button in countdown sections +- **Advance**: Use Rest/Crit Success buttons or manual +/- controls +- **Smart Grouping**: Pips automatically group in sets of 5 for easy counting +- **Types**: Standard, Progress, Consequence, Long-term, Fear, Hope -## Static Hosting +### Adversary Tracking +- **Add**: Browse database or create custom adversaries +- **Damage**: Click difficulty shield for damage input system +- **HP/Stress**: Tap pips to increment/decrement +- **Reorder**: Drag to reorder in edit mode + +### Environment Management +- **Add**: Browse database or create custom environments +- **Effects**: Track environmental effects and aspects +- **Reorder**: Drag to reorder in edit mode + +## Deployment This app can be hosted on any static hosting service: +- **GitHub Pages**: `./deploy.sh` (configured) - **Vercel**: `vercel --prod` - **Netlify**: Drag and drop the `dist` folder -- **GitHub Pages**: Deploy from the `dist` folder - **Firebase Hosting**: `firebase deploy` -## State Management +## Contributing + +We welcome contributions! Check out our [GitHub Issues](https://github.com/Splinter714/Daggerheart/issues) for areas that need work: + +- **UX Improvements**: Better mobile interactions, design refinements +- **Feature Enhancements**: New functionality and game mechanics +- **Performance**: Optimization and code improvements +- **Accessibility**: Better a11y support -- **localStorage**: All game state is saved to your browser +## Privacy & Data + +- **Local Storage**: All data stays in your browser +- **No Tracking**: No analytics or data collection - **Private**: Each user has their own separate game state -- **Persistent**: State survives browser restarts -- **No server required**: Completely client-side +- **Offline**: Works without internet connection + +## Recent Updates + +- **Smart Countdown Grouping**: 5-pip groups for better readability +- **Mobile Optimization**: Smooth swipe gestures and touch interactions +- **Component Refactor**: Organized, modular codebase +- **Persistent Filters**: Browser filters save across sessions +- **Damage Input System**: Restored with threshold calculations +- **Icon Updates**: Intuitive pencil/trashcan/wrench icons +- **Performance**: Optimized rendering and state management + +--- + +Happy gaming! + +*Built with love for the Daggerheart community* + +--- + +## Daggerheart™ Compatibility + +![Community Content Logo](assets/logos/Darrington%20Press%20Community%20Content%20Logos/Daggerheart/PNGs/DH_CGL_logos_final_full_color.png) + +This project uses only **Public Game Content** from the **Daggerheart SRD 1.0** and follows the +**Darrington Press Community Gaming License (DPCGL)**. + +> This product includes materials from the Daggerheart System Reference Document 1.0, © Critical Role, LLC, under the terms of the Darrington Press Community Gaming (DPCGL) License. More information can be found at https://www.daggerheart.com. There are no previous modifications by others. + +- The DPCGL (PDF) is included at `Darrington-Press-CGL.pdf`. +- This project is **unofficial** and **not endorsed** by Darrington Press or Critical Role. +- Per the DPCGL, "Daggerheart" is not used in the product title; compatibility is stated here in descriptive text. -Happy gaming! 🎲✨ diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..89ebee3 --- /dev/null +++ b/TODO.md @@ -0,0 +1,26 @@ +# Daggerheart GM Dashboard - TODO List + +## Current Status +- **Version**: 0.1.13 +- **Last Updated**: September 2025 + +## TODO Items + +- [ ] Modularize all view/edit section pairings to prevent layout shifts when moving sections +- [ ] Make the interactable HP/stress/difficulty section identical to the right half of the compact card +- [ ] Make the title font the same as the compact card +- [ ] Tighten up the core stats section - lots of extra space there +- [ ] Work on description and motives text box extra-line behavior +- [ ] Make experiences editable in edit mode and sync layout exactly with view mode +- [ ] Make features sections editable and sync layout exactly with view mode +- [ ] Create nice low-friction way to add new experiences and features without many button presses +- [ ] Separate out the numbering for duplicate adversaries from their name so it's not part of the name edit process +- [ ] Evaluate global vs local edit mode approach +- [ ] Continue re-design of editor/creator/expanded card +- [ ] Evaluate delete/clear flow UX +- [ ] Evaluate UI position of countdown trigger buttons +- [ ] Evaluate damage input popup UX + +--- + +*This TODO list tracks the specific items you want to work on. Update as needed.* \ No newline at end of file diff --git a/archive/PWAInstallPrompt.jsx b/archive/PWAInstallPrompt.jsx new file mode 100644 index 0000000..71ff07f --- /dev/null +++ b/archive/PWAInstallPrompt.jsx @@ -0,0 +1,74 @@ +import React, { useState, useEffect } from 'react' + +const PWAInstallPrompt = () => { + const [deferredPrompt, setDeferredPrompt] = useState(null) + const [showInstallPrompt, setShowInstallPrompt] = useState(false) + + useEffect(() => { + const handleBeforeInstallPrompt = (e) => { + // Prevent the mini-infobar from appearing on mobile + e.preventDefault() + // Stash the event so it can be triggered later + setDeferredPrompt(e) + // Show the install prompt + setShowInstallPrompt(true) + } + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt) + } + }, []) + + const handleInstallClick = async () => { + if (!deferredPrompt) return + + // Show the install prompt + deferredPrompt.prompt() + + // Wait for the user to respond to the prompt + const { outcome } = await deferredPrompt.userChoice + + if (outcome === 'accepted') { + console.log('User accepted the install prompt') + } else { + console.log('User dismissed the install prompt') + } + + // Clear the deferredPrompt + setDeferredPrompt(null) + setShowInstallPrompt(false) + } + + const handleDismiss = () => { + setShowInstallPrompt(false) + } + + if (!showInstallPrompt) return null + + return ( +
+
+

Install Daggerheart GM Dashboard

+

Install this app on your device for quick access and offline use.

+
+ + +
+
+
+ ) +} + +export default PWAInstallPrompt diff --git a/archive/browser/Browser.jsx b/archive/browser/Browser.jsx new file mode 100644 index 0000000..1024991 --- /dev/null +++ b/archive/browser/Browser.jsx @@ -0,0 +1,343 @@ +import React, { useMemo, useEffect, useRef, useState } from 'react' +// removed unused imports after split +import useBrowser from '../../hooks/useBrowser' +import BrowserHeader from './BrowserHeader' +import BrowserTableHeader from './BrowserTableHeader' +import BrowserRow from './BrowserRow' +// Dynamically import JSON data to keep initial bundle smaller +let adversariesData = { adversaries: [] } +let environmentsData = { environments: [] } +let _dataLoaded = false + +// Load data asynchronously +const loadData = async () => { + try { + const mod = await import(/* @vite-ignore */ '../../src/data/adversaries.json') + adversariesData = mod?.default || mod + console.log('Loaded adversaries data:', adversariesData) + } catch (e) { + console.warn('Failed to load adversaries.json dynamically:', e) + } + try { + const mod = await import(/* @vite-ignore */ '../../src/data/environments.json') + environmentsData = mod?.default || mod + console.log('Loaded environments data:', environmentsData) + console.log('Environments array:', environmentsData.environments) + } catch (e) { + console.warn('Failed to load environments.json dynamically:', e) + } + _dataLoaded = true +} + +// Load data immediately and store the promise +const dataLoadPromise = loadData() + +const Browser = ({ + type, // 'adversary' or 'environment' + onAddItem, + onCancel, + onCreateCustom +}) => { + const [_dataLoadState, setDataLoadState] = useState(0) + + // Wait for data to load and force re-render + useEffect(() => { + dataLoadPromise.then(() => { + setDataLoadState(prev => prev + 1) + }) + }, []) + + console.log('Browser component rendered with props:', { type, onAddItem, onCancel, onCreateCustom }) + console.log('Browser component is being used, not List component') + + const { + searchTerm, setSearchTerm, + selectedTiers, setSelectedTiers, + selectedTypes, setSelectedTypes, + sortFields, + showTierDropdown, setShowTierDropdown, + showTypeDropdown, setShowTypeDropdown, + tierFilterRef, typeFilterRef, + expandedCard, setExpandedCard, + handleSort, handleFilter, + tierTooltip, typeTooltip, isTierFiltered, isTypeFiltered, + handleTierSelect, handleTypeSelect, + getDropdownStyle + } = useBrowser(type) + + // expandedCard handled by hook + + // (mobile detection removed) + + // Track the current type to detect changes + const currentTypeRef = useRef(type) + + // Handle type changes without useEffect to prevent jitter + if (currentTypeRef.current !== type) { + currentTypeRef.current = type + // Sort fields are now handled by usePersistentState in the hook + // No need to manually load them here + } + + // (persisted via usePersistentState) + + // persistence handled by hook + + // (persisted via usePersistentState) + + // filters load handled by hook + + // (moved below itemTypes/itemTiers) + + // Use imported data directly - only the relevant type + const adversaryData = adversariesData.adversaries || [] + const environmentData = environmentsData.environments || [] + + console.log('Browser render - type:', type, 'adversaryData length:', adversaryData.length, 'environmentData length:', environmentData.length) + console.log('environmentsData structure:', environmentsData) + + // Select data based on type + const currentData = type === 'adversary' ? adversaryData : environmentData + const dataType = type === 'adversary' ? 'adversary' : 'environment' + + // Close dropdowns on outside interaction (capture phase to bypass local stopPropagation) + useEffect(() => { + const handleGlobalDown = (event) => { + const inDropdown = event.target.closest('.filter-dropdown') + const onFilterButton = event.target.closest('.header-filter-icon') + if (inDropdown || onFilterButton) return + setShowTierDropdown(false) + setShowTypeDropdown(false) + } + const handleKey = (event) => { + if (event.key === 'Escape') { + setShowTierDropdown(false) + setShowTypeDropdown(false) + } + } + + document.addEventListener('pointerdown', handleGlobalDown, true) + document.addEventListener('mousedown', handleGlobalDown, true) + document.addEventListener('touchstart', handleGlobalDown, true) + document.addEventListener('click', handleGlobalDown, true) + document.addEventListener('keydown', handleKey, true) + return () => { + document.removeEventListener('pointerdown', handleGlobalDown, true) + document.removeEventListener('mousedown', handleGlobalDown, true) + document.removeEventListener('touchstart', handleGlobalDown, true) + document.removeEventListener('click', handleGlobalDown, true) + document.removeEventListener('keydown', handleKey, true) + } + }, [setShowTierDropdown, setShowTypeDropdown]) + + // Combine and prepare data for unified view + const unifiedData = useMemo(() => { + const items = currentData.map(item => ({ + ...item, + category: dataType === 'adversary' ? 'Adversary' : 'Environment', + displayType: item.type, + displayDifficulty: item.difficulty === 'Special (see Relative Strength)' ? 'Relative' : item.difficulty + })) + + return items + }, [currentData, dataType]) + + // Get unique types for filter + const itemTypes = useMemo(() => { + const types = [...new Set(unifiedData.map(item => item.displayType).filter(Boolean))] + return types.sort() + }, [unifiedData]) + + // Get unique tiers for filter + const itemTiers = useMemo(() => { + const tiers = [...new Set(unifiedData.map(item => item.tier).filter(Boolean))] + return tiers.sort((a, b) => a - b) + }, [unifiedData]) + + // Reconcile saved filters with available data to avoid empty results + // DISABLED: This was causing table loading issues on refresh + // The filters should be trusted as-is from localStorage + /* + useEffect(() => { + // Only reconcile if we have data loaded AND types/tiers are available + if (currentData.length === 0 || itemTypes.length === 0 || itemTiers.length === 0) return + + // Add a small delay to ensure all state is properly initialized + const timeoutId = setTimeout(() => { + // Types + if (selectedTypes.length > 0) { + const validTypes = new Set(itemTypes) + const nextTypes = selectedTypes.filter(t => validTypes.has(t)) + if (nextTypes.length !== selectedTypes.length) { + setSelectedTypes(nextTypes.length > 0 ? nextTypes : []) + } + } + // Tiers (compare as strings) + if (selectedTiers.length > 0) { + const validTiers = new Set(itemTiers.map(t => String(t))) + const nextTiers = selectedTiers.filter(t => validTiers.has(String(t))) + if (nextTiers.length !== selectedTiers.length) { + setSelectedTiers(nextTiers.length > 0 ? nextTiers : []) + } + } + }, 100) // Small delay to ensure state is stable + + return () => clearTimeout(timeoutId) + }, [currentData.length, itemTypes.length, itemTiers.length, selectedTypes, selectedTiers, setSelectedTypes, setSelectedTiers]) + */ + + // Sort and filter items + const filteredAndSortedItems = useMemo(() => { + let filtered = unifiedData.filter(item => { + const matchesSearch = searchTerm === '' || + item.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (item.description && item.description.toLowerCase().includes(searchTerm.toLowerCase())) + + const matchesTier = selectedTiers.length === 0 || selectedTiers.includes(item.tier.toString()) + const matchesType = selectedTypes.length === 0 || selectedTypes.includes(item.displayType) + + return matchesSearch && matchesTier && matchesType + }) + + // Apply multi-level sorting + filtered.sort((a, b) => { + for (const sort of sortFields) { + const { field, direction } = sort + let aValue = a[field] + let bValue = b[field] + + if (field === 'tier') { + aValue = parseInt(aValue) || 0 + bValue = parseInt(bValue) || 0 + } else if (field === 'displayDifficulty') { + // Handle difficulty sorting - numeric difficulties first, then special cases + const aNum = parseInt(aValue) + const bNum = parseInt(bValue) + + if (!isNaN(aNum) && !isNaN(bNum)) { + // Both are numeric + aValue = aNum + bValue = bNum + } else if (!isNaN(aNum) && isNaN(bNum)) { + // a is numeric, b is not - numeric comes first + aValue = aNum + bValue = 999 // High number to sort after numeric difficulties + } else if (isNaN(aNum) && !isNaN(bNum)) { + // b is numeric, a is not - numeric comes first + aValue = 999 // High number to sort after numeric difficulties + bValue = bNum + } else { + // Both are non-numeric, sort alphabetically + aValue = String(aValue || '').toLowerCase() + bValue = String(bValue || '').toLowerCase() + } + } else if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase() + bValue = bValue.toLowerCase() + } else { + // Handle non-string values by converting to strings + aValue = String(aValue || '').toLowerCase() + bValue = String(bValue || '').toLowerCase() + } + + if (direction === 'asc') { + if (aValue > bValue) return 1 + if (aValue < bValue) return -1 + } else { + if (aValue < bValue) return 1 + if (aValue > bValue) return -1 + } + // If equal, continue to next sort level + } + return 0 + }) + + return filtered + }, [unifiedData, searchTerm, selectedTiers, selectedTypes, sortFields]) + + // handlers and tooltips provided by hook + + // dropdown style provided by hook + + const handleAddFromDatabase = (itemData) => { + console.log('Browser handleAddFromDatabase called with:', itemData) + + // Add the item to the game state + if (onAddItem) { + onAddItem(itemData, type) + } + } + + const handleCardClick = (item) => { + console.log('Browser handleCardClick called:', item.name) + if (expandedCard?.id === item.id) { + setExpandedCard(null) + console.log('Collapsing card') + } else { + setExpandedCard(item) + console.log('Expanding card:', item.name, 'expandedCard state:', expandedCard) + } + } + + return ( +
+ {/* Fixed Header Row */} + + + {/* Scrollable Content */} +
+ + + + handleFilter('tier')} + onToggleTypeDropdown={() => handleFilter('type')} + itemTiers={itemTiers} + itemTypes={itemTypes} + selectedTiers={selectedTiers} + selectedTypes={selectedTypes} + onClearTiers={() => setSelectedTiers([])} + onClearTypes={() => setSelectedTypes([])} + onTierSelect={(tier) => handleTierSelect(tier)} + onTypeSelect={(v) => handleTypeSelect(v)} + getDropdownStyle={getDropdownStyle} + /> + + + {filteredAndSortedItems.map((item) => ( + + ))} + +
+
+ +
+ ) +} + +export default Browser \ No newline at end of file diff --git a/archive/browser/BrowserHeader.jsx b/archive/browser/BrowserHeader.jsx new file mode 100644 index 0000000..72c59b9 --- /dev/null +++ b/archive/browser/BrowserHeader.jsx @@ -0,0 +1,29 @@ +import React from 'react' +import Button from '../controls/Buttons' + +const BrowserHeader = ({ searchTerm, onSearchChange, onCreateCustom, type }) => { + return ( +
+ onSearchChange(e.target.value)} + className="browser-search-input" + /> + + +
+ ) +} + +export default BrowserHeader + + diff --git a/archive/browser/BrowserRow.jsx b/archive/browser/BrowserRow.jsx new file mode 100644 index 0000000..7bee547 --- /dev/null +++ b/archive/browser/BrowserRow.jsx @@ -0,0 +1,69 @@ +import React from 'react' +import Button from '../controls/Buttons' +import { Plus } from 'lucide-react' +import Cards from '../cards/Cards' + +const BrowserRow = ({ item, isExpanded, onToggleExpand, onAdd, fallbackType }) => { + const cardType = item.category?.toLowerCase() || fallbackType + const expandedItem = { + ...item, + hp: 0, + stress: 0 + } + + return ( + <> + { + e.preventDefault() + e.stopPropagation() + onToggleExpand(item) + }} + > + {item.name} + {item.tier} + {item.displayType} + {item.displayDifficulty} + + + + + + {isExpanded && ( + + +
+ +
+ + + )} + + {isExpanded && ( + + +
+ +
+ + + )} + + ) +} + +export default BrowserRow + + diff --git a/archive/browser/BrowserTableHeader.jsx b/archive/browser/BrowserTableHeader.jsx new file mode 100644 index 0000000..2b3f2ec --- /dev/null +++ b/archive/browser/BrowserTableHeader.jsx @@ -0,0 +1,159 @@ +import React from 'react' +import { createPortal } from 'react-dom' +import { Filter, Square, CheckSquare } from 'lucide-react' + +const BrowserTableHeader = ({ + sortFields, + onSort, + tierFilterRef, + typeFilterRef, + isTierFiltered, + isTypeFiltered, + tierTooltip, + typeTooltip, + showTierDropdown, + showTypeDropdown, + onToggleTierDropdown, + onToggleTypeDropdown, + itemTiers, + itemTypes, + selectedTiers, + selectedTypes, + onClearTiers, + onClearTypes, + onTierSelect, + onTypeSelect, + getDropdownStyle +}) => { + return ( + + onSort('name')} + className={`sortable ${sortFields[0]?.field === 'name' ? 'active' : ''} ${sortFields[0]?.field === 'name' ? sortFields[0].direction : ''}`} + > + Name + + onSort('tier')} + className={`sortable ${sortFields[0]?.field === 'tier' ? 'active' : ''} ${sortFields[0]?.field === 'tier' ? sortFields[0].direction : ''}`} + > +
+ Tier + + {showTierDropdown && createPortal( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
onClearTiers()} + > + {selectedTiers.length === 0 ? : } + All +
+ {itemTiers.map(tier => { + const isSelected = selectedTiers.includes(tier.toString()) + return ( +
onTierSelect(tier.toString())} + > + {isSelected ? : } + {tier} +
+ ) + })} +
, + document.body + )} +
+ + onSort('displayType')} + className={`sortable ${sortFields[0]?.field === 'displayType' ? 'active' : ''} ${sortFields[0]?.field === 'displayType' ? sortFields[0].direction : ''}`} + > +
+ Type + + {showTypeDropdown && createPortal( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > +
onClearTypes()} + > + {selectedTypes.length === 0 ? : } + All +
+ {itemTypes.map(type => { + const isSelected = selectedTypes.includes(type) + return ( +
onTypeSelect(type)} + > + {isSelected ? : } + {type} +
+ ) + })} +
, + document.body + )} +
+ + onSort('displayDifficulty')} + className={`sortable ${sortFields[0]?.field === 'displayDifficulty' ? 'active' : ''} ${sortFields[0]?.field === 'displayDifficulty' ? sortFields[0].direction : ''}`} + > +
+ Diff +
+ + + + ) +} + +export default BrowserTableHeader + + diff --git a/archive/cards/AdversaryCard.jsx b/archive/cards/AdversaryCard.jsx new file mode 100644 index 0000000..e119f3b --- /dev/null +++ b/archive/cards/AdversaryCard.jsx @@ -0,0 +1,307 @@ +import React, { useState } from 'react' +import { Droplet, Activity, CheckCircle } from 'lucide-react' +import Button from '../controls/Buttons' +import { TypeBadge, DifficultyBadge } from '../ui/Badges' +import CompactCardShell from './CompactCardShell' + +const AdversaryCard = ({ + item, + mode, + onClick, + onDelete, + onApplyDamage, + onApplyHealing, + onApplyStressChange, + dragAttributes, + dragListeners, +}) => { + const [showDamageInput, setShowDamageInput] = useState(false) + const [damageValue, setDamageValue] = useState('') + + if (mode !== 'compact') return null + + const baseName = item.baseName || item.name?.replace(/\s+\(\d+\)$/, '') || '' + const duplicateNumber = item.duplicateNumber || (item.name?.match(/\((\d+)\)$/) ? parseInt(item.name.match(/\((\d+)\)$/)[1]) : 1) + + return ( + = (item.hpMax || 1) ? 'dead' : ''}`} + item={item} + onClick={onClick} + dragAttributes={dragAttributes} + dragListeners={dragListeners} + > +
+

+ {baseName} ({duplicateNumber}) +

+
+ {item.type && } +
+
+
+
+
+
{ + e.stopPropagation() + e.preventDefault() + const currentHp = item.hp || 0 + const hpMax = item.hpMax || 1 + const symbolsRect = e.currentTarget.getBoundingClientRect() + const clickX = e.clientX - symbolsRect.left + const symbolWidth = symbolsRect.width / hpMax + const clickedIndex = Math.floor(clickX / symbolWidth) + + if (clickedIndex < currentHp) { + // Clicked on filled pip - heal (reduce damage) + onApplyHealing && onApplyHealing(item.id, 1, item.hp) + } else { + // Clicked on empty pip - take damage + onApplyDamage && onApplyDamage(item.id, 1, item.hp, item.hpMax) + } + }} + > + {Array.from({ length: item.hpMax || 1 }, (_, i) => ( + + + + ))} +
+
+ {item.stressMax > 0 && ( +
+
{ + e.stopPropagation() + e.preventDefault() + const currentStress = item.stress || 0 + const stressMax = item.stressMax || 1 + const symbolsRect = e.currentTarget.getBoundingClientRect() + const clickX = e.clientX - symbolsRect.left + const symbolWidth = symbolsRect.width / stressMax + const clickedIndex = Math.floor(clickX / symbolWidth) + + if (clickedIndex < currentStress) { + // Clicked on filled pip - reduce stress + onApplyStressChange && onApplyStressChange(item.id, -1, item.stress, item.stressMax) + } else { + // Clicked on empty pip - increase stress + onApplyStressChange && onApplyStressChange(item.id, 1, item.stress, item.stressMax) + } + }} + > + {Array.from({ length: item.stressMax }, (_, i) => ( + + + + ))} +
+
+ )} +
+ {item.difficulty && ( +
+
{ + e.stopPropagation() + if ((item.thresholds && item.thresholds.major && item.thresholds.severe) || item.type === 'Minion') { + setShowDamageInput(true) + setDamageValue(item.type === 'Minion' ? '1' : '') + } + }} + className={((item.thresholds && item.thresholds.major && item.thresholds.severe) || item.type === 'Minion') ? 'cursor-pointer' : 'cursor-default'} + title={(item.thresholds && item.thresholds.major && item.thresholds.severe) ? `Click to enter damage (thresholds: ${item.thresholds.major}/${item.thresholds.severe})` : item.type === 'Minion' ? 'Click to enter damage (minion mechanics)' : ''} + > + +
+
+ )} +
+ {dragAttributes && dragListeners && ( + + )} +
+
+ + {/* Damage Input Popup */} + {showDamageInput && ((item.thresholds && item.thresholds.major && item.thresholds.severe) || item.type === 'Minion') && ( +
{ + // Prevent event propagation to underlying card + e.stopPropagation() + + // Close if clicking outside the input content + if (e.target === e.currentTarget) { + setShowDamageInput(false) + setDamageValue('') + } + }} + > +
e.stopPropagation()} + > + setDamageValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const damage = parseInt(damageValue) + if (damage > 0) { + if (item.type === 'Minion') { + // For minions, apply the raw damage amount (minion mechanics handle the rest) + onApplyDamage && onApplyDamage(item.id, damage, item.hp, item.hpMax) + } else { + // Calculate HP damage based on damage thresholds for regular adversaries + let hpDamage = 0 + if (damage >= item.thresholds.severe) { + hpDamage = 3 // Severe damage + } else if (damage >= item.thresholds.major) { + hpDamage = 2 // Major damage + } else if (damage >= 1) { + hpDamage = 1 // Minor damage + } + onApplyDamage && onApplyDamage(item.id, hpDamage, item.hp, item.hpMax) + } + setShowDamageInput(false) + setDamageValue('') + } + } else if (e.key === 'Escape') { + setShowDamageInput(false) + setDamageValue('') + } + }} + autoFocus + /> +
+ {item.type === 'Minion' ? ( + // Minion damage indicator - show how many additional minions can be defeated + (() => { + const damage = parseInt(damageValue) || 0 + const minionFeature = item.features?.find(f => f.name?.startsWith('Minion (')) + const minionThreshold = minionFeature ? parseInt(minionFeature.name.match(/\((\d+)\)/)?.[1] || '1') : 1 + const additionalMinions = Math.floor(damage / minionThreshold) + + // Only show additional minions if we've hit the threshold + if (damage >= minionThreshold && additionalMinions > 0) { + return ( + + +{additionalMinions} additional minion(s) + + ) + } else { + // Show placeholder to prevent layout shift + return ( + + +0 additional minion(s) + + ) + } + })() + ) : ( + // Regular adversary damage indicators + [1, 2, 3].map((level) => { + const damage = parseInt(damageValue) || 0 + let isActive = false + if (level === 1 && damage >= 1) isActive = true + if (level === 2 && damage >= item.thresholds.major) isActive = true + if (level === 3 && damage >= item.thresholds.severe) isActive = true + + return ( + { + e.stopPropagation() + // Set the input value to the threshold amount for this level + if (level === 1) { + setDamageValue('1') + } else if (level === 2) { + setDamageValue(item.thresholds.major.toString()) + } else if (level === 3) { + setDamageValue(item.thresholds.severe.toString()) + } + }} + title={`Click to set damage to ${level === 1 ? '1' : level === 2 ? item.thresholds.major : item.thresholds.severe}`} + > + + + ) + }) + )} + +
+
+
+ )} +
+ ) +} + +export default AdversaryCard + + diff --git a/archive/cards/AdversaryCoreStatsSection.jsx b/archive/cards/AdversaryCoreStatsSection.jsx new file mode 100644 index 0000000..c939390 --- /dev/null +++ b/archive/cards/AdversaryCoreStatsSection.jsx @@ -0,0 +1,297 @@ +import React from 'react' + +const AdversaryCoreStatsSection = ({ item, editData, isEditMode, onInputChange, onExperienceChange, onAddExperience, onRemoveExperience }) => { + return ( +
+ {/* Primary Stats Row */} +
+ {isEditMode ? ( + <> + + Diff: + onInputChange('difficulty', e.target.value === '' ? '' : parseInt(e.target.value))} + min="1" + max="21" + /> + + | + + Thresholds: + onInputChange('thresholds', { ...editData.thresholds, major: e.target.value === '' ? '' : parseInt(e.target.value) })} + min="1" + max="20" + /> + / + onInputChange('thresholds', { ...editData.thresholds, severe: e.target.value === '' ? '' : parseInt(e.target.value) })} + min="1" + max="25" + /> + + | + + HP: + onInputChange('hpMax', e.target.value === '' ? '' : parseInt(e.target.value))} + min="1" + max="12" + /> + + | + + Stress: + onInputChange('stressMax', e.target.value === '' ? '' : parseInt(e.target.value))} + min="1" + max="10" + /> + + + ) : ( + <> + + Diff: + + + | + + Thresholds: + + / + + + | + + HP: + + + | + + Stress: + + + + )} +
+ + {/* Combat Stats Row */} +
+ {isEditMode ? ( + <> + + ATK: + onInputChange('atk', e.target.value === '' ? '' : parseInt(e.target.value))} + min="-10" + max="10" + /> + + | + + + onInputChange('weapon', e.target.value)} + placeholder="Weapon" + /> + : + + + | + + onInputChange('damage', e.target.value)} + placeholder="1d12+2 phy" + /> + + + ) : ( + <> + + ATK: + + + | + + + + : + + + | + + + + + )} +
+ + {/* Experience Row */} + {item.experience && item.experience.length > 0 && ( +
+ {isEditMode ? ( + <> + + Experience: +
+ {item.experience.map((exp, index) => ( +
+ onExperienceChange(index, 'name', e.target.value)} + placeholder="Experience name" + /> + {typeof exp === 'object' && ( + <> + onExperienceChange(index, 'modifier', parseInt(e.target.value) || 0)} + placeholder="Modifier" + min="-10" + max="10" + /> + + + )} +
+ ))} + +
+
+ + ) : ( + + Experience: {item.experience.map(exp => + typeof exp === 'string' ? exp : `${exp.name} ${exp.modifier >= 0 ? '+' : ''}${exp.modifier}` + ).join(', ')} + + )} +
+ )} +
+ ) +} + +export default AdversaryCoreStatsSection diff --git a/archive/cards/AdversaryDescriptionSection.jsx b/archive/cards/AdversaryDescriptionSection.jsx new file mode 100644 index 0000000..19cccb1 --- /dev/null +++ b/archive/cards/AdversaryDescriptionSection.jsx @@ -0,0 +1,94 @@ +import React from 'react' + +const AdversaryDescriptionSection = ({ item, editData, isEditMode, onInputChange, descriptionHeight, motivesHeight, onDescriptionResize, onMotivesResize }) => { + const handleDescriptionChange = (e) => { + onInputChange('description', e.target.value) + + // Auto-resize textarea + const textarea = e.target + textarea.style.height = 'auto' + const scrollHeight = textarea.scrollHeight + const lineHeight = parseInt(window.getComputedStyle(textarea).lineHeight) + const minHeight = lineHeight + + // Set height to content height or minimum single line height + const newHeight = Math.max(scrollHeight, minHeight) + 'px' + textarea.style.height = newHeight + onDescriptionResize(newHeight) + } + + const handleMotivesChange = (e) => { + onInputChange('motives', e.target.value) + + // Auto-resize textarea + const textarea = e.target + textarea.style.height = 'auto' + const scrollHeight = textarea.scrollHeight + const lineHeight = parseInt(window.getComputedStyle(textarea).lineHeight) + const minHeight = lineHeight + + // Set height to content height or minimum single line height + const newHeight = Math.max(scrollHeight, minHeight) + 'px' + textarea.style.height = newHeight + onMotivesResize(newHeight) + + // Update the container height to match + const container = textarea.parentElement + if (container) { + container.style.height = newHeight + } + } + + return ( +
+ {isEditMode ? ( + <> +