feat: add comprehensive bibliography and citation system

Implements complete bibliography management with citation assignment to
nodes and edges, following CSL-JSON standard.

Features:
- Bibliography store with Zustand and citation.js integration
- Smart import supporting DOI, BibTeX, PubMed ID, and URLs
- Manual entry and editing forms for references
- Citation selector with autocomplete text field interface
- History tracking for undo/redo support
- Workspace integration for import/export
- Support for multiple reference types including interview and other
- Description/notes field for additional reference information

Components:
- CitationSelector: autocomplete UI for selecting citations
- BibliographyConfigModal: main bibliography management interface
- QuickAddReferenceForm: smart import and manual entry
- EditReferenceInline: full reference editing form
- ReferenceManagementList: list view with citation counts

Integration:
- NodeEditorPanel: citation assignment to actors
- EdgeEditorPanel: citation assignment to relations
- MenuBar: bibliography menu item
- WorkspaceStore: bibliography persistence in workspace files

Technical details:
- CSL-JSON format for bibliographic data
- citation.js for parsing and formatting
- TypeScript with proper type definitions
- Debounced updates for performance
- Citation count tracking across graph elements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-17 14:43:55 +02:00
parent c1cd2d3114
commit 36f44d61ac
21 changed files with 4585 additions and 2 deletions

File diff suppressed because it is too large Load diff

638
UX_BIBLIOGRAPHY_DESIGN.md Normal file
View file

@ -0,0 +1,638 @@
# Bibliography System - UX Design Concept
## Executive Summary
This document outlines the UX design for integrating a citation/reference management system into Constellation Analyzer. The design prioritizes ease of use for social scientists who may not be technically versed, while maintaining flexibility for power users.
---
## 1. User Research & Requirements
### Target Users
- **Primary**: Social scientists conducting constellation analyses
- **Technical level**: Low to medium (not LaTeX/BibTeX users)
- **Use case**: Track sources for actors and relationships in their analysis
- **Workflow**: Need quick access to add/edit/cite references without interrupting graph work
### Key Requirements
- Simple, form-based reference entry (not code-based like BibTeX)
- Support common social science citation types (books, articles, websites, reports)
- Quick citation from node/edge properties
- Export capability for academic writing
- Integration with existing document structure
---
## 2. Data Format Decision: CSL-JSON
### Why CSL-JSON over BibTeX?
**CSL-JSON Advantages:**
- ✅ Human-readable JSON format
- ✅ Industry standard (Zotero, Mendeley, Papers)
- ✅ 10,000+ pre-built citation styles available
- ✅ Better support for non-English sources
- ✅ UTF-8 native support
- ✅ Easier to parse and manipulate programmatically
- ✅ Can be converted to/from BibTeX for power users
**BibTeX Limitations:**
- ❌ Complex syntax (`@article{key, field={value}}`)
- ❌ Requires understanding of LaTeX conventions
- ❌ Less intuitive for non-technical users
- ❌ ASCII-focused (problematic for international names)
### CSL-JSON Structure (Simplified)
```json
{
"id": "unique-id",
"type": "article-journal",
"title": "Understanding Social Networks",
"author": [
{"family": "Smith", "given": "Jane"},
{"family": "Doe", "given": "John"}
],
"issued": {"date-parts": [[2023]]},
"container-title": "Journal of Social Sciences",
"volume": "45",
"issue": "3",
"page": "123-145",
"DOI": "10.1000/example",
"URL": "https://example.com/article"
}
```
### Supported Reference Types
1. **article-journal** - Journal articles
2. **book** - Books
3. **chapter** - Book chapters
4. **paper-conference** - Conference papers
5. **report** - Technical reports, white papers
6. **thesis** - Theses and dissertations
7. **webpage** - Web pages and online sources
8. **interview** - Interviews (common in social sciences)
9. **manuscript** - Unpublished manuscripts
10. **personal_communication** - Personal communications
---
## 3. UI Component Design
### 3.1 Access Points
**Primary Access: Menu Bar**
```
Edit Menu:
├── Configure Actor Types
├── Configure Relation Types
├── Configure Labels
├── [NEW] Manage Bibliography (Ctrl+B)
```
**Secondary Access: Property Panels**
- Add "Citations" field to Node Editor Panel
- Add "Citations" field to Edge Editor Panel
**Tertiary Access: Export Menu**
```
File → Export → Bibliography (CSL-JSON)
File → Export → Bibliography (BibTeX) [for power users]
File → Export → Formatted Bibliography (HTML/PDF)
```
---
### 3.2 Bibliography Management Modal
**Layout: Two-Column Design** (consistent with existing Label/Type config modals)
```
┌─────────────────────────────────────────────────────────────────────┐
│ Manage Bibliography [X] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────┬──────────────────────────────────┐ │
│ │ Quick Add Reference │ Your References (15) │ │
│ │ (60% width) │ (40% width) │ │
│ │ │ │ │
│ │ [Reference Type ▼] │ ┌─────────────────────────────┐│ │
│ │ ○ Journal Article │ │ Smith & Doe (2023) ││ │
│ │ ○ Book │ │ Understanding Social... ││ │
│ │ ○ Website │ │ Journal of Social Sciences ││ │
│ │ ○ Other... │ │ [Edit] [Delete] [Cite] ││ │
│ │ │ └─────────────────────────────┘│ │
│ │ Smart Input: │ │ │
│ │ ┌─────────────────────┐ │ ┌─────────────────────────────┐│ │
│ │ │ Paste DOI, URL, or │ │ │ Johnson (2022) ││ │
│ │ │ formatted citation │ │ │ Network Theory in... ││ │
│ │ └─────────────────────┘ │ │ Academic Press ││ │
│ │ [Auto-Fill] [Clear] │ │ [Edit] [Delete] [Cite] ││ │
│ │ │ └─────────────────────────────┘│ │
│ │ -- OR Manual Entry -- │ │ │
│ │ │ [Search references...] │ │
│ │ Title * │ Filter: [All Types ▼] │ │
│ │ ┌─────────────────────┐ │ │ │
│ │ │ │ │ Sort by: [Recent ▼] │ │
│ │ └─────────────────────┘ │ │ │
│ │ │ │ │
│ │ Authors (one per line) │ │ │
│ │ ┌─────────────────────┐ │ │ │
│ │ │ Jane Smith │ │ │ │
│ │ │ John Doe │ │ │ │
│ │ └─────────────────────┘ │ │ │
│ │ │ │ │
│ │ Year * │ │ │
│ │ ┌────┐ │ │ │
│ │ │2023│ │ │ │
│ │ └────┘ │ │ │
│ │ │ │ │
│ │ [+ Show More Fields] │ │ │
│ │ │ │ │
│ │ [Add Reference] │ │ │
│ └──────────────────────────┴──────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 💡 Tip: Paste a DOI or URL for automatic citation import │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ [Import File] [Close] │
└─────────────────────────────────────────────────────────────────────┘
```
**Edit Mode: Full-Width** (when editing existing reference)
```
┌─────────────────────────────────────────────────────────────────────┐
│ Edit Reference [X] │
├─────────────────────────────────────────────────────────────────────┤
│ ← Back to List │
│ │
│ Reference Type: [Journal Article ▼] │
│ │
│ Title * │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Understanding Social Networks in Constellation Analysis │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ Authors (one per line or separated by semicolons) * │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Jane Smith; John Doe │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ Year * [2023] Volume [45] Issue [3] Pages [123-145] │
│ │
│ Journal/Container Title │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Journal of Social Sciences │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ DOI (Optional) URL (Optional) │
│ ┌───────────────────────────┐ ┌───────────────────────────────┐ │
│ │ 10.1000/example │ │ https://example.com/article │ │
│ └───────────────────────────┘ └───────────────────────────────┘ │
│ │
│ Abstract/Notes (Optional) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ Tags (comma separated) │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ social networks, methodology, qualitative │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Preview: │ │
│ │ Smith, J., & Doe, J. (2023). Understanding Social Networks │ │
│ │ in Constellation Analysis. Journal of Social Sciences, │ │
│ │ 45(3), 123-145. https://doi.org/10.1000/example │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ Citation Format: [APA 7th ▼] │
│ │
│ [Cancel] [Save Changes] │
└─────────────────────────────────────────────────────────────────────┘
```
---
### 3.3 Citation Field in Property Panels
**Node Editor Panel - Add "Citations" Section** (after Labels, before Connections)
```
┌───────────────────────────────┐
│ Actor Properties │
├───────────────────────────────┤
│ Actor Type: [Researcher ▼] │
│ Label: Jane Smith │
│ Description: ... │
│ Labels: [methodology] │
│ │
│ Citations (2) │
│ ┌───────────────────────────┐ │
│ │ • Smith & Doe (2023) │ │
│ │ • Johnson (2022) │ │
│ │ [+ Add Citation] │ │
│ └───────────────────────────┘ │
│ │
│ Connections (3) │
│ ... │
└───────────────────────────────┘
```
**Citation Selection Dropdown**
When user clicks [+ Add Citation]:
```
┌───────────────────────────────────┐
│ Add Citation │
├───────────────────────────────────┤
│ Search references... │
│ ┌───────────────────────────────┐ │
│ │ 🔍 smith │ │
│ └───────────────────────────────┘ │
│ │
│ Results (2): │
│ ┌───────────────────────────────┐ │
│ │ ☑ Smith & Doe (2023) │ │
│ │ Understanding Social... │ │
│ │ J. Social Sciences │ │
│ ├───────────────────────────────┤ │
│ │ ☐ Smithson (2021) │ │
│ │ Network Analysis Methods │ │
│ │ Academic Press │ │
│ └───────────────────────────────┘ │
│ │
│ [+ Create New Reference] │
│ │
│ [Cancel] [Add (1)] │
└───────────────────────────────────┘
```
**Features:**
- Multi-select checkbox list
- Real-time search filtering
- Shows abbreviated citation info
- Quick create if reference doesn't exist
- Selected references show checkmarks
---
### 3.4 Reference Card Design (in Management List)
```
┌─────────────────────────────────────────────┐
│ Smith & Doe (2023) [📋][✏️][🗑️] │
│ Understanding Social Networks in... │
│ Journal of Social Sciences │
│ ─────────────────────────────────────────── │
│ 🏷️ social networks, methodology │
│ 📊 Cited by: 3 actors, 2 relations │
└─────────────────────────────────────────────┘
```
**Card Components:**
1. **Header**: Short citation (Author-Date)
2. **Title**: Truncated with ellipsis
3. **Container**: Journal/Book/Website name
4. **Tags**: Visual tag chips
5. **Usage**: Count of actors/relations citing this
6. **Actions**: Copy citation, Edit, Delete
---
## 4. User Workflows
### 4.1 Workflow: Quick Add Reference (Smart Import)
**Scenario**: User has a DOI or formatted citation
1. Open "Manage Bibliography" (Ctrl+B or Edit menu)
2. Paste DOI/URL in "Smart Input" field
3. Click "Auto-Fill"
4. System fetches metadata and populates fields
5. User reviews/adjusts if needed
6. Click "Add Reference"
7. Toast: "Reference added successfully"
**Fallback**: If auto-fill fails, form remains with manual entry option
---
### 4.2 Workflow: Manual Reference Entry
**Scenario**: User has a book without DOI
1. Open "Manage Bibliography"
2. Select reference type: "Book"
3. Form adjusts to show relevant fields:
- Title, Authors, Year (required)
- Publisher, Place, ISBN, Pages (optional)
4. Fill in required fields
5. Click "Add Reference"
6. Reference appears in list on right
---
### 4.3 Workflow: Cite Reference in Node
**Scenario**: User wants to cite sources for an actor
1. Select node in graph
2. Right panel shows Node Editor
3. Scroll to "Citations" section
4. Click [+ Add Citation]
5. Search dropdown appears
6. Type to filter or browse list
7. Check box(es) for desired reference(s)
8. Click "Add"
9. Citations appear as bullet list with author-date
10. Change auto-saves after 500ms debounce
---
### 4.4 Workflow: Edit Existing Reference
**Scenario**: User needs to correct publication year
1. Open "Manage Bibliography"
2. Find reference in right column list
3. Click [Edit] button
4. Modal switches to full-width edit mode
5. Modify year field
6. Preview updates in real-time
7. Click "Save Changes"
8. Returns to two-column view
9. Toast: "Reference updated"
**Note**: All actors/relations citing this reference show updated info
---
### 4.5 Workflow: Delete Reference with Usage
**Scenario**: User deletes reference cited by 5 items
1. Click [Delete] on reference card
2. Confirmation dialog appears:
```
Delete Reference?
This reference is cited by:
• 3 actors
• 2 relations
Deleting will remove all citations to this reference.
[Cancel] [Delete Reference]
```
3. User confirms
4. Reference deleted from bibliography
5. All citation links removed from actors/relations
6. Toast: "Reference deleted"
---
### 4.6 Workflow: Export Bibliography
**Scenario**: User wants formatted bibliography for paper
1. File → Export → Formatted Bibliography
2. Dialog appears:
```
Export Bibliography
Format: [APA 7th Edition ▼]
Options:
☑ Include only cited references
☐ Include all references
☑ Sort alphabetically by author
Output:
○ HTML (for web/Word)
○ PDF
○ Plain text
[Cancel] [Export]
```
3. User selects options
4. Click "Export"
5. File save dialog
6. Toast: "Bibliography exported successfully"
---
## 5. Design Principles
### 5.1 Progressive Disclosure
- **Basic mode**: Show only essential fields (Title, Author, Year)
- **Advanced mode**: "Show More Fields" reveals DOI, ISBN, Volume, etc.
- **Smart defaults**: Type selection pre-fills appropriate fields
### 5.2 Forgiveness & Flexibility
- **Auto-save**: No manual save needed (consistent with current app)
- **Undo support**: Bibliography changes tracked in history
- **Validation**: Soft validation (warnings, not errors)
- **Import/Export**: Allow bulk operations for power users
### 5.3 Consistency with Existing Patterns
- **Modal layout**: Two-column → Full-width (like Labels/Types)
- **Toast notifications**: All actions get feedback
- **Confirmation dialogs**: Destructive operations require confirmation
- **Debounced updates**: 500ms delay for citation assignments
- **Color coding**: None needed (unlike types), but tags use label-like chips
### 5.4 Social Science Focus
- **Citation preview**: Always show formatted citation (APA/Chicago/MLA)
- **Interview type**: Support non-traditional sources
- **Qualitative notes**: Abstract field for researcher notes
- **Tagging**: Organize by themes, not just bibliographic categories
---
## 6. Technical Considerations
### 6.1 Data Storage
**Document Structure** (extend ConstellationDocument):
```typescript
export interface ConstellationDocument {
// ... existing fields
bibliography?: {
references: BibliographyReference[];
settings?: {
defaultStyle: string; // e.g., "apa-7th"
sortOrder: 'author' | 'year' | 'title';
}
}
}
```
**Node/Edge Structure** (extend ActorData/RelationData):
```typescript
export interface ActorData {
// ... existing fields
citations?: string[]; // Array of reference IDs
}
export interface RelationData {
// ... existing fields
citations?: string[]; // Array of reference IDs
}
```
### 6.2 Import/Export
**Supported Formats:**
1. **CSL-JSON** (primary)
- Native format
- Import from Zotero, Mendeley
- Export for use in other tools
2. **BibTeX** (secondary, for power users)
- Convert CSL-JSON ↔ BibTeX
- Use existing libraries (citation-js, bibtex-parser)
3. **RIS** (tertiary, for compatibility)
- Common in older reference managers
- Convert via citation-js
4. **Formatted HTML/PDF**
- For inclusion in papers/reports
- Use CSL processor (citeproc-js)
### 6.3 Auto-Fill Service
**DOI Lookup**:
- Use CrossRef API (free, no auth required)
- Endpoint: `https://api.crossref.org/works/{doi}`
- Returns JSON metadata
**URL Metadata**:
- Use Open Graph tags or Dublin Core metadata
- Fallback to page title/author extraction
- Not as reliable as DOI
**Fallback**:
- If lookup fails, show error toast
- Keep form populated with what was entered
- Allow manual completion
### 6.4 Citation Style Library
**Approach**: Use Citation Style Language (CSL)
**Libraries**:
- `citeproc-js`: CSL processor for formatting
- `citation-js`: Parse/convert between formats
- Preload common styles: APA 7th, Chicago, MLA, Harvard
**Style Switching**:
- User can change preview style in modal
- Setting saved per document
- All citations reformat instantly
---
## 7. Accessibility & Usability
### 7.1 Keyboard Navigation
- `Ctrl+B`: Open bibliography modal
- `Tab`: Navigate between fields
- `Enter`: Submit forms (add/save)
- `Esc`: Close modal/cancel
- `/` in search: Focus search input
### 7.2 Screen Reader Support
- Form labels with `aria-label`
- Button descriptions
- Status announcements for actions
- Semantic HTML (fieldset, legend, label)
### 7.3 Visual Design
- **High contrast**: Readable citation text
- **Clear hierarchy**: Title > Authors > Container
- **Action buttons**: Icon + text for clarity
- **Tooltips**: Help text for advanced fields
---
## 8. Future Enhancements (Not MVP)
### Phase 2
- **Duplicate detection**: Warn if similar reference exists
- **Citation count**: Show most/least cited references
- **Bulk import**: CSV, BibTeX file upload
- **Collaboration**: Shared bibliography across workspace
- **Citation network**: Graph of which references cite each other
### Phase 3
- **PDF import**: Drag PDF, extract citation
- **Web clipper**: Browser extension for one-click import
- **Smart suggestions**: Recommend related references
- **Citation graph**: Visualize citation relationships as constellation
- **Integration**: Export to LaTeX/Word with in-text citations
---
## 9. Success Metrics
### User Experience
- ✅ Non-technical users can add reference in <2 minutes
- ✅ 90% of DOI lookups succeed automatically
- ✅ Zero training needed (intuitive interface)
- ✅ No "Save" button confusion (auto-save)
### Technical
- ✅ Bibliography data persists with document
- ✅ Import/export works with Zotero/Mendeley
- ✅ No performance impact on graph rendering
- ✅ Undo/redo works for bibliography changes
### Adoption
- ✅ Users cite at least 1 reference per document
- ✅ Bibliography modal opened in >50% of sessions
- ✅ Export feature used by academic users
---
## 10. Implementation Phases
### Phase 1: Core Functionality (MVP)
1. Bibliography modal (two-column layout)
2. CRUD operations (Create, Read, Update, Delete)
3. Basic CSL-JSON storage
4. Citation field in node/edge properties
5. Simple formatted preview (APA only)
6. Export as CSL-JSON
### Phase 2: Smart Features
1. DOI auto-fill
2. Multiple citation styles (APA, Chicago, MLA)
3. Import from BibTeX
4. Export formatted bibliography (HTML)
5. Tag filtering and search
6. Usage tracking (citation counts)
### Phase 3: Power User Features
1. Bulk operations
2. Advanced search/filter
3. Custom citation styles
4. PDF import
5. Duplicate detection
6. Citation graph visualization
---
## Conclusion
This design balances simplicity for non-technical social scientists with power features for advanced users. By using CSL-JSON as the underlying format, we ensure compatibility with popular reference managers while keeping the UI approachable. The two-column modal pattern maintains consistency with existing app design, reducing the learning curve.
**Key Innovation**: Smart input field that accepts DOI/URL and auto-fills, dramatically reducing manual entry burden while still allowing full manual control.
**Next Steps**: Create technical specification and begin implementation with Phase 1 (MVP).

224
package-lock.json generated
View file

@ -8,6 +8,11 @@
"name": "constellation-analyzer", "name": "constellation-analyzer",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@citation-js/core": "^0.7.18",
"@citation-js/plugin-bibtex": "^0.7.18",
"@citation-js/plugin-csl": "^0.7.18",
"@citation-js/plugin-doi": "^0.7.18",
"@citation-js/plugin-ris": "^0.7.18",
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10", "@mui/icons-material": "^5.15.10",
@ -326,6 +331,96 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@citation-js/core": {
"version": "0.7.18",
"resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.18.tgz",
"integrity": "sha512-EjLuZWA5156dIFGdF7OnyPyWFBW43B8Ckje6Sn/W2RFxHDu0oACvW4/6TNgWT80jhEA4bVFm7ahrZe9MJ2B2UQ==",
"dependencies": {
"@citation-js/date": "^0.5.0",
"@citation-js/name": "^0.4.2",
"fetch-ponyfill": "^7.1.0",
"sync-fetch": "^0.4.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@citation-js/date": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@citation-js/date/-/date-0.5.1.tgz",
"integrity": "sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@citation-js/name": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@citation-js/name/-/name-0.4.2.tgz",
"integrity": "sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/@citation-js/plugin-bibtex": {
"version": "0.7.18",
"resolved": "https://registry.npmjs.org/@citation-js/plugin-bibtex/-/plugin-bibtex-0.7.18.tgz",
"integrity": "sha512-TdsZSMpgpfcx2NMPu0KiulEoecllwT5EtRUzAJl2pDsdPD1tUqqbyj/NBi0l8fwNy1r7WwAqSFGiqGPjQWpFdg==",
"dependencies": {
"@citation-js/date": "^0.5.0",
"@citation-js/name": "^0.4.2",
"moo": "^0.5.1"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@citation-js/core": "^0.7.0"
}
},
"node_modules/@citation-js/plugin-csl": {
"version": "0.7.18",
"resolved": "https://registry.npmjs.org/@citation-js/plugin-csl/-/plugin-csl-0.7.18.tgz",
"integrity": "sha512-cJcOdEZurmtIxNj0d4cOERHpVQJB/mN3YPSDNqfI/xTFRN3bWDpFAsaqubPtMO2ZPpoDS+ZGIP1kggbwCfMmlA==",
"dependencies": {
"@citation-js/date": "^0.5.0",
"citeproc": "^2.4.6"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@citation-js/core": "^0.7.0"
}
},
"node_modules/@citation-js/plugin-doi": {
"version": "0.7.18",
"resolved": "https://registry.npmjs.org/@citation-js/plugin-doi/-/plugin-doi-0.7.18.tgz",
"integrity": "sha512-7ccmhfJJSDUhUqpWxesLAp3m1P5dhnZ4QNMctwJnU41T9vKGF7MXPKqMONSvL5JDZ7o7iWQTj2BFhSmh0euQxw==",
"dependencies": {
"@citation-js/date": "^0.5.0"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@citation-js/core": "^0.7.0"
}
},
"node_modules/@citation-js/plugin-ris": {
"version": "0.7.18",
"resolved": "https://registry.npmjs.org/@citation-js/plugin-ris/-/plugin-ris-0.7.18.tgz",
"integrity": "sha512-aHd8isGGxz6P63UjyTR4RfX1alqxu8PQLWeoUBcnTMAPmyn6qzJfdB+iZEFA8HyL4g3I33n431zL/0BV8XlJiw==",
"dependencies": {
"@citation-js/date": "^0.5.0",
"@citation-js/name": "^0.4.2"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@citation-js/core": "^0.7.0"
}
},
"node_modules/@emotion/babel-plugin": { "node_modules/@emotion/babel-plugin": {
"version": "11.13.5", "version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@ -2410,6 +2505,25 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "dev": true
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.14", "version": "2.8.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.14.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.14.tgz",
@ -2485,6 +2599,29 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -2574,6 +2711,11 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/citeproc": {
"version": "2.4.63",
"resolved": "https://registry.npmjs.org/citeproc/-/citeproc-2.4.63.tgz",
"integrity": "sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q=="
},
"node_modules/classcat": { "node_modules/classcat": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
@ -3173,6 +3315,14 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-ponyfill": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz",
"integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==",
"dependencies": {
"node-fetch": "~2.6.1"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@ -3438,6 +3588,25 @@
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz",
"integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==" "integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA=="
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3814,6 +3983,11 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3854,6 +4028,25 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true "dev": true
}, },
"node_modules/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.23", "version": "2.0.23",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
@ -4806,6 +4999,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sync-fetch": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.4.5.tgz",
"integrity": "sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==",
"dependencies": {
"buffer": "^5.7.1",
"node-fetch": "^2.6.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.18", "version": "3.4.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
@ -4882,6 +5087,11 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
@ -5048,6 +5258,20 @@
} }
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -10,6 +10,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@citation-js/core": "^0.7.18",
"@citation-js/plugin-bibtex": "^0.7.18",
"@citation-js/plugin-csl": "^0.7.18",
"@citation-js/plugin-doi": "^0.7.18",
"@citation-js/plugin-ris": "^0.7.18",
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10", "@mui/icons-material": "^5.15.10",

View file

@ -0,0 +1,247 @@
import { useState, useRef, useEffect } from 'react';
import { useBibliographyStore } from '../../stores/bibliographyStore';
import { formatShortCitation } from '../../utils/bibliography/formatting';
import CloseIcon from '@mui/icons-material/Close';
import MenuBookIcon from '@mui/icons-material/MenuBook';
import type { BibliographyReference } from '../../types/bibliography';
interface CitationSelectorProps {
value: string[];
onChange: (citationIds: string[]) => void;
onOpenBibliography?: () => void;
}
const CitationSelector = ({ value, onChange, onOpenBibliography }: CitationSelectorProps) => {
const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { getReferences } = useBibliographyStore();
const allReferences = getReferences();
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setInputValue('');
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
// Filter references by search query
const filteredReferences = allReferences.filter(ref => {
const query = inputValue.toLowerCase();
const title = (ref.title || '').toLowerCase();
const authors = ref.author?.map(a =>
a.literal || `${a.given || ''} ${a.family || ''}`.trim()
).join(' ').toLowerCase() || '';
const year = ref.issued?.['date-parts']?.[0]?.[0]?.toString() || '';
return title.includes(query) || authors.includes(query) || year.includes(query);
});
// Get selected references
const selectedReferences = value
.map(id => allReferences.find(ref => ref.id === id))
.filter((ref): ref is BibliographyReference => ref !== undefined);
const handleToggleReference = (refId: string) => {
if (value.includes(refId)) {
onChange(value.filter(id => id !== refId));
} else {
onChange([...value, refId]);
}
};
const handleRemoveReference = (refId: string, e: React.MouseEvent) => {
e.stopPropagation();
onChange(value.filter(id => id !== refId));
};
// Keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen && e.key !== 'Escape') {
setIsOpen(true);
return;
}
const totalOptions = filteredReferences.length;
if (totalOptions === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((prev) => (prev + 1) % totalOptions);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev - 1 + totalOptions) % totalOptions);
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < totalOptions) {
handleToggleReference(filteredReferences[highlightedIndex].id);
setInputValue('');
setHighlightedIndex(0);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
setInputValue('');
inputRef.current?.blur();
break;
}
};
// Reset highlighted index when filtered references change
useEffect(() => {
setHighlightedIndex(0);
}, [inputValue]);
return (
<div className="relative" ref={dropdownRef}>
{/* Selected Citations Display */}
{selectedReferences.length > 0 && (
<div className="mb-2 space-y-1">
{selectedReferences.map(ref => (
<div
key={ref.id}
className="flex items-start justify-between gap-2 px-2 py-1.5 bg-blue-50 border border-blue-200 rounded text-xs"
>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 line-clamp-1">
{ref.title || 'Untitled'}
</div>
<div className="text-gray-600 text-xs">
{formatShortCitation(ref)}
</div>
</div>
<button
onClick={(e) => handleRemoveReference(ref.id, e)}
className="flex-shrink-0 p-0.5 text-gray-500 hover:text-red-600 transition-colors"
aria-label="Remove citation"
>
<CloseIcon sx={{ fontSize: 14 }} />
</button>
</div>
))}
</div>
)}
{/* Text Input Field */}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Type to search citations..."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-64 flex flex-col">
{/* Reference List */}
<div className="flex-1 overflow-y-auto">
{filteredReferences.length === 0 ? (
<div className="p-4 text-center">
{allReferences.length === 0 ? (
<div className="space-y-2">
<p className="text-sm text-gray-500">No references yet.</p>
{onOpenBibliography && (
<button
onClick={() => {
onOpenBibliography();
setIsOpen(false);
setInputValue('');
}}
className="text-sm text-blue-600 hover:text-blue-700 font-medium flex items-center justify-center gap-1"
>
<MenuBookIcon sx={{ fontSize: 16 }} />
Add References
</button>
)}
</div>
) : (
<p className="text-sm text-gray-500">No matching references.</p>
)}
</div>
) : (
<div className="py-1">
{filteredReferences.map((ref, index) => {
const isSelected = value.includes(ref.id);
const isHighlighted = index === highlightedIndex;
return (
<button
key={ref.id}
onClick={() => {
handleToggleReference(ref.id);
setInputValue('');
setHighlightedIndex(0);
}}
onMouseEnter={() => setHighlightedIndex(index)}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
isHighlighted ? 'bg-gray-100' : isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}} // Handled by button onClick
className="mt-0.5 flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 line-clamp-1">
{ref.title || 'Untitled'}
</div>
<div className="text-xs text-gray-600">
{formatShortCitation(ref)}
</div>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
{allReferences.length > 0 && onOpenBibliography && (
<div className="p-2 border-t border-gray-200">
<button
onClick={() => {
onOpenBibliography();
setIsOpen(false);
setInputValue('');
}}
className="w-full px-2 py-1.5 text-xs text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded transition-colors flex items-center justify-center gap-1"
>
<MenuBookIcon sx={{ fontSize: 14 }} />
Manage Bibliography
</button>
</div>
)}
</div>
)}
</div>
);
};
export default CitationSelector;

View file

@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import { useBibliographyStore } from '../../stores/bibliographyStore';
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
import QuickAddReferenceForm from './QuickAddReferenceForm';
import ReferenceManagementList from './ReferenceManagementList';
import EditReferenceInline from './EditReferenceInline';
interface BibliographyConfigProps {
isOpen: boolean;
onClose: () => void;
initialEditingReferenceId?: string | null;
}
const BibliographyConfig = ({ isOpen, onClose, initialEditingReferenceId = null }: BibliographyConfigProps) => {
const [editingReferenceId, setEditingReferenceId] = useState<string | null>(initialEditingReferenceId);
const { getReferences } = useBibliographyStore();
const { deleteReference: deleteReferenceWithHistory } = useBibliographyWithHistory();
const references = getReferences();
// Handle initial editing reference if provided
useEffect(() => {
if (isOpen && initialEditingReferenceId) {
setEditingReferenceId(initialEditingReferenceId);
}
}, [isOpen, initialEditingReferenceId]);
// Close modal on Escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
if (editingReferenceId) {
setEditingReferenceId(null);
} else {
onClose();
}
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose, editingReferenceId]);
const handleEditReference = (id: string) => {
setEditingReferenceId(id);
};
const handleCancelEdit = () => {
setEditingReferenceId(null);
};
const handleDeleteReference = (id: string) => {
deleteReferenceWithHistory(id);
};
const handleDone = () => {
setEditingReferenceId(null);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={handleDone}
aria-hidden="true"
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Bibliography Manager</h2>
<p className="text-sm text-gray-600 mt-1">
Add and manage references for your constellation analysis. Import from DOI, URL, BibTeX, or enter manually.
</p>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex min-h-0">
{editingReferenceId ? (
// Edit mode - full width
<EditReferenceInline
referenceId={editingReferenceId}
onCancel={handleCancelEdit}
/>
) : (
// Two-column layout
<>
{/* Left Column - Quick Add */}
<div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
<QuickAddReferenceForm />
</div>
{/* Right Column - Reference List */}
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
<ReferenceManagementList
references={references}
onEdit={handleEditReference}
onDelete={handleDeleteReference}
/>
</div>
</>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
onClick={handleDone}
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
>
Done
</button>
</div>
</div>
</div>
);
};
export default BibliographyConfig;

View file

@ -0,0 +1,364 @@
import { useState, useEffect, KeyboardEvent } from 'react';
import { useBibliographyStore } from '../../stores/bibliographyStore';
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
import { useToastStore } from '../../stores/toastStore';
import type { CSLReference } from '../../types/bibliography';
interface EditReferenceInlineProps {
referenceId: string;
onCancel: () => void;
}
const EditReferenceInline = ({ referenceId, onCancel }: EditReferenceInlineProps) => {
const { getReferenceById } = useBibliographyStore();
const { updateReference } = useBibliographyWithHistory();
const { showToast } = useToastStore();
const reference = getReferenceById(referenceId);
const [title, setTitle] = useState('');
const [type, setType] = useState<CSLReference['type']>('article-journal');
const [authors, setAuthors] = useState('');
const [year, setYear] = useState('');
const [journal, setJournal] = useState('');
const [volume, setVolume] = useState('');
const [issue, setIssue] = useState('');
const [pages, setPages] = useState('');
const [doi, setDoi] = useState('');
const [url, setUrl] = useState('');
const [abstract, setAbstract] = useState('');
const [note, setNote] = useState('');
// Load reference data
useEffect(() => {
if (reference) {
setTitle(reference.title || '');
setType(reference.type || 'article-journal');
// Parse authors
if (reference.author) {
const authorStr = reference.author
.map(a => {
if (a.literal) return a.literal;
return [a.given, a.family].filter(Boolean).join(' ');
})
.join(', ');
setAuthors(authorStr);
}
// Parse year
if (reference.issued?.['date-parts']?.[0]?.[0]) {
setYear(reference.issued['date-parts'][0][0].toString());
}
setJournal(reference['container-title'] || '');
setVolume(reference.volume?.toString() || '');
setIssue(reference.issue?.toString() || '');
setPages(reference.page || '');
setDoi(reference.DOI || '');
setUrl(reference.URL || '');
setAbstract(reference.abstract || '');
setNote(reference.note || '');
}
}, [reference]);
const handleSave = () => {
if (!title.trim()) {
showToast('Title is required', 'error');
return;
}
const updates: Partial<CSLReference> = {
title: title.trim(),
type,
};
// Parse authors
if (authors.trim()) {
const authorList = authors
.split(/,| and /i)
.map(name => {
const parts = name.trim().split(' ');
if (parts.length >= 2) {
return {
given: parts.slice(0, -1).join(' '),
family: parts[parts.length - 1],
};
}
return { literal: name.trim() };
});
updates.author = authorList;
}
// Parse year
if (year.trim()) {
const yearNum = parseInt(year, 10);
if (!isNaN(yearNum)) {
updates.issued = { 'date-parts': [[yearNum]] };
}
}
// Add other fields if present
if (journal.trim()) updates['container-title'] = journal.trim();
if (volume.trim()) updates.volume = volume.trim();
if (issue.trim()) updates.issue = issue.trim();
if (pages.trim()) updates.page = pages.trim();
if (doi.trim()) updates.DOI = doi.trim();
if (url.trim()) updates.URL = url.trim();
if (abstract.trim()) updates.abstract = abstract.trim();
if (note.trim()) updates.note = note.trim();
updateReference(referenceId, updates);
showToast('Reference updated successfully', 'success');
onCancel();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
if (!reference) {
return (
<div className="w-full p-6 text-center text-gray-500">
Reference not found
</div>
);
}
const isMac = navigator.platform.includes('Mac');
return (
<div className="w-full p-6 overflow-y-auto">
<div className="max-w-2xl mx-auto">
<div className="flex flex-col min-h-full">
<div className="flex-1 mb-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Edit Reference</h3>
{/* Title */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Title <span className="text-red-500">*</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Type */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Type
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as CSLReference['type'])}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="article-journal">Journal Article</option>
<option value="book">Book</option>
<option value="chapter">Book Chapter</option>
<option value="paper-conference">Conference Paper</option>
<option value="thesis">Thesis</option>
<option value="report">Report</option>
<option value="webpage">Web Page</option>
<option value="article-newspaper">Newspaper Article</option>
<option value="interview">Interview</option>
<option value="no-type">Other</option>
</select>
</div>
{/* Authors */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Authors
</label>
<input
type="text"
value={authors}
onChange={(e) => setAuthors(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., John Doe, Jane Smith"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Year, Volume, Issue, Pages */}
<div className="grid grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Year
</label>
<input
type="text"
value={year}
onChange={(e) => setYear(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="2024"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Volume
</label>
<input
type="text"
value={volume}
onChange={(e) => setVolume(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="42"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Issue
</label>
<input
type="text"
value={issue}
onChange={(e) => setIssue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="3"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Pages
</label>
<input
type="text"
value={pages}
onChange={(e) => setPages(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="123-145"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Journal/Container Title */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Journal / Publication
</label>
<input
type="text"
value={journal}
onChange={(e) => setJournal(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Journal of Example Studies"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* DOI and URL */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
DOI
</label>
<input
type="text"
value={doi}
onChange={(e) => setDoi(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="10.1234/example"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
URL
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="https://example.com"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Abstract */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Abstract
</label>
<textarea
value={abstract}
onChange={(e) => setAbstract(e.target.value)}
placeholder="Brief summary of the work..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* Description/Note */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Description / Notes
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Additional information about this reference..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
{/* Action Buttons */}
<div className="pt-6 border-t border-gray-200 space-y-3">
<button
onClick={handleSave}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Save Changes
</button>
<button
onClick={onCancel}
className="w-full px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
Cancel
</button>
<p className="text-xs text-center text-gray-500">
Press{' '}
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
{isMac ? '⌘' : 'Ctrl'}+Enter
</kbd>{' '}
to save,{' '}
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
Esc
</kbd>{' '}
to cancel
</p>
</div>
</div>
</div>
</div>
);
};
export default EditReferenceInline;

View file

@ -0,0 +1,319 @@
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
import { useBibliographyStore } from '../../stores/bibliographyStore';
import { useToastStore } from '../../stores/toastStore';
import { isValidCitationInput, getInputTypeHint } from '../../utils/bibliography/smart-parser';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import AddIcon from '@mui/icons-material/Add';
import type { CSLReference } from '../../types/bibliography';
const QuickAddReferenceForm = () => {
const [smartInput, setSmartInput] = useState('');
const [manualEntry, setManualEntry] = useState(false);
const [title, setTitle] = useState('');
const [authors, setAuthors] = useState('');
const [year, setYear] = useState('');
const [type, setType] = useState<CSLReference['type']>('article-journal');
const [description, setDescription] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const smartInputRef = useRef<HTMLTextAreaElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const { addReference } = useBibliographyWithHistory();
const { parseInput } = useBibliographyStore();
const { showToast } = useToastStore();
// Auto-focus appropriate field
useEffect(() => {
if (manualEntry) {
titleInputRef.current?.focus();
} else {
smartInputRef.current?.focus();
}
}, [manualEntry]);
const resetForm = () => {
setSmartInput('');
setTitle('');
setAuthors('');
setYear('');
setType('article-journal');
setDescription('');
setManualEntry(false);
};
const handleSmartAdd = async () => {
if (!smartInput.trim()) return;
setIsProcessing(true);
try {
// Use citation.js to parse the input
const parsed = await parseInput(smartInput.trim());
if (parsed.length > 0) {
// Add all parsed references
parsed.forEach(ref => addReference(ref));
showToast(
parsed.length === 1
? 'Reference added successfully'
: `${parsed.length} references added successfully`,
'success'
);
resetForm();
smartInputRef.current?.focus();
} else {
showToast('Could not parse citation data', 'error');
}
} catch (error) {
console.error('Parse error:', error);
showToast('Failed to parse input. Try manual entry instead.', 'error');
} finally {
setIsProcessing(false);
}
};
const handleManualAdd = () => {
if (!title.trim()) {
showToast('Title is required', 'error');
return;
}
const ref: Partial<CSLReference> = {
type,
title: title.trim(),
};
// Parse authors (simplified - split by comma or "and")
if (authors.trim()) {
const authorList = authors
.split(/,| and /i)
.map(name => {
const parts = name.trim().split(' ');
if (parts.length >= 2) {
return {
given: parts.slice(0, -1).join(' '),
family: parts[parts.length - 1],
};
}
return { literal: name.trim() };
});
ref.author = authorList;
}
// Parse year
if (year.trim()) {
const yearNum = parseInt(year, 10);
if (!isNaN(yearNum)) {
ref.issued = { 'date-parts': [[yearNum]] };
}
}
// Add description/note if present
if (description.trim()) {
ref.note = description.trim();
}
addReference(ref);
showToast('Reference added successfully', 'success');
resetForm();
titleInputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (manualEntry) {
handleManualAdd();
} else {
handleSmartAdd();
}
} else if (e.key === 'Escape') {
e.preventDefault();
resetForm();
}
};
const inputTypeHint = smartInput.trim() ? getInputTypeHint(smartInput) : null;
const isValidInput = smartInput.trim() ? isValidCitationInput(smartInput) : false;
const hasManualContent = title.trim() || authors.trim() || year.trim() || description.trim();
return (
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Quick Add Reference</h3>
{/* Toggle between Smart and Manual */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setManualEntry(false)}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
!manualEntry
? 'bg-blue-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<AutoAwesomeIcon className="text-sm mr-1" />
Smart Import
</button>
<button
onClick={() => setManualEntry(true)}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
manualEntry
? 'bg-blue-600 text-white'
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<AddIcon className="text-sm mr-1" />
Manual Entry
</button>
</div>
</div>
{!manualEntry ? (
// Smart Import Mode
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
DOI, URL, BibTeX, or PubMed ID
</label>
<textarea
ref={smartInputRef}
value={smartInput}
onChange={(e) => setSmartInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Paste DOI (10.1234/example), URL, BibTeX entry, or PubMed ID..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono"
/>
{inputTypeHint && (
<p className={`text-xs mt-1 ${isValidInput ? 'text-green-600' : 'text-orange-600'}`}>
Detected: {inputTypeHint}
</p>
)}
</div>
<button
onClick={handleSmartAdd}
disabled={!smartInput.trim() || isProcessing}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{isProcessing ? 'Processing...' : 'Import Reference'}
</button>
{smartInput.trim() && (
<p className="text-xs text-gray-500">
Press <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
</kbd> to add
</p>
)}
</div>
) : (
// Manual Entry Mode
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Title <span className="text-red-500">*</span>
</label>
<input
ref={titleInputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter reference title"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Authors
</label>
<input
type="text"
value={authors}
onChange={(e) => setAuthors(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., John Doe, Jane Smith"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Year
</label>
<input
type="text"
value={year}
onChange={(e) => setYear(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="2024"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Type
</label>
<select
value={type}
onChange={(e) => setType(e.target.value as CSLReference['type'])}
onKeyDown={handleKeyDown}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="article-journal">Journal Article</option>
<option value="book">Book</option>
<option value="chapter">Book Chapter</option>
<option value="paper-conference">Conference Paper</option>
<option value="thesis">Thesis</option>
<option value="report">Report</option>
<option value="webpage">Web Page</option>
<option value="article-newspaper">Newspaper Article</option>
<option value="interview">Interview</option>
<option value="no-type">Other</option>
</select>
</div>
</div>
{/* Description/Note */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Description / Notes
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Additional information about this reference..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<button
onClick={handleManualAdd}
disabled={!title.trim()}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Reference
</button>
{hasManualContent && (
<p className="text-xs text-gray-500">
Press <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
</kbd> to add
</p>
)}
</div>
)}
</div>
);
};
export default QuickAddReferenceForm;

View file

@ -0,0 +1,134 @@
import { useBibliographyWithHistory } from '../../hooks/useBibliographyWithHistory';
import { formatShortCitation } from '../../utils/bibliography/formatting';
import { useConfirm } from '../../hooks/useConfirm';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import type { BibliographyReference } from '../../types/bibliography';
interface ReferenceManagementListProps {
references: BibliographyReference[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
}
const ReferenceManagementList = ({ references, onEdit, onDelete }: ReferenceManagementListProps) => {
const { getCitationCount } = useBibliographyWithHistory();
const { confirm } = useConfirm();
const handleDelete = async (ref: BibliographyReference) => {
const citationCount = getCitationCount(ref.id);
const totalCitations = citationCount.nodes + citationCount.edges;
const message =
totalCitations > 0
? `Delete "${ref.title}"? This reference is cited ${totalCitations} time${totalCitations > 1 ? 's' : ''} (${citationCount.nodes} actors, ${citationCount.edges} relations). Citations will be removed.`
: `Delete "${ref.title}"?`;
const confirmed = await confirm({
title: 'Delete Reference',
message,
severity: totalCitations > 0 ? 'warning' : 'info',
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
});
if (confirmed) {
onDelete(ref.id);
}
};
if (references.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p className="text-sm">No references yet.</p>
<p className="text-xs mt-1">Add your first reference using the form on the left.</p>
</div>
);
}
return (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900 mb-3">
All References ({references.length})
</h3>
{references.map((ref) => {
const shortCitation = formatShortCitation(ref);
const citationCount = getCitationCount(ref.id);
const totalCitations = citationCount.nodes + citationCount.edges;
return (
<div
key={ref.id}
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all"
>
<div className="flex items-start justify-between gap-2">
<div
className="flex-1 cursor-pointer min-w-0"
onClick={() => onEdit(ref.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEdit(ref.id);
}
}}
>
<div className="font-medium text-sm text-gray-900 line-clamp-2">
{ref.title || 'Untitled'}
</div>
<div className="text-xs text-gray-600 mt-1">
{shortCitation}
</div>
{/* Reference Type Badge */}
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
{ref.type.replace(/-/g, ' ')}
</span>
{/* Citation Count */}
{totalCitations > 0 && (
<span className="text-xs text-gray-500">
{totalCitations} citation{totalCitations > 1 ? 's' : ''}
</span>
)}
{/* DOI indicator */}
{ref.DOI && (
<span className="text-xs text-blue-600">
DOI
</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => onEdit(ref.id)}
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Edit reference"
title="Edit reference"
>
<EditIcon className="text-base" />
</button>
<button
onClick={() => handleDelete(ref)}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label="Delete reference"
title="Delete reference"
>
<DeleteIcon className="text-base" />
</button>
</div>
</div>
</div>
);
})}
</div>
);
};
export default ReferenceManagementList;

View file

@ -7,6 +7,7 @@ import DocumentManager from '../Workspace/DocumentManager';
import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import LabelConfigModal from '../Config/LabelConfig'; import LabelConfigModal from '../Config/LabelConfig';
import BibliographyConfigModal from '../Config/BibliographyConfig';
import InputDialog from '../Common/InputDialog'; import InputDialog from '../Common/InputDialog';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import { useShortcutLabels } from '../../hooks/useShortcutLabels'; import { useShortcutLabels } from '../../hooks/useShortcutLabels';
@ -34,6 +35,7 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
const [showNodeConfig, setShowNodeConfig] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false);
const [showEdgeConfig, setShowEdgeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false);
const [showLabelConfig, setShowLabelConfig] = useState(false); const [showLabelConfig, setShowLabelConfig] = useState(false);
const [showBibliographyConfig, setShowBibliographyConfig] = useState(false);
const [showNewDocDialog, setShowNewDocDialog] = useState(false); const [showNewDocDialog, setShowNewDocDialog] = useState(false);
const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false); const [showNewFromTemplateDialog, setShowNewFromTemplateDialog] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -180,6 +182,11 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
closeMenu(); closeMenu();
}, [closeMenu]); }, [closeMenu]);
const handleManageBibliography = useCallback(() => {
setShowBibliographyConfig(true);
closeMenu();
}, [closeMenu]);
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
undo(); undo();
closeMenu(); closeMenu();
@ -386,6 +393,12 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
> >
Configure Labels Configure Labels
</button> </button>
<button
onClick={handleManageBibliography}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Manage Bibliography
</button>
<hr className="my-1 border-gray-200" /> <hr className="my-1 border-gray-200" />
@ -482,6 +495,10 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onExport }) =>
isOpen={showLabelConfig} isOpen={showLabelConfig}
onClose={() => setShowLabelConfig(false)} onClose={() => setShowLabelConfig(false)}
/> />
<BibliographyConfigModal
isOpen={showBibliographyConfig}
onClose={() => setShowBibliographyConfig(false)}
/>
{/* Input Dialogs */} {/* Input Dialogs */}
<InputDialog <InputDialog

View file

@ -12,7 +12,9 @@ import { useConfirm } from '../../hooks/useConfirm';
import ConnectionDisplay from '../Common/ConnectionDisplay'; import ConnectionDisplay from '../Common/ConnectionDisplay';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import LabelConfigModal from '../Config/LabelConfig'; import LabelConfigModal from '../Config/LabelConfig';
import BibliographyConfigModal from '../Config/BibliographyConfig';
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector'; import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
import CitationSelector from '../Common/CitationSelector';
import type { Relation, EdgeDirectionality } from '../../types'; import type { Relation, EdgeDirectionality } from '../../types';
interface EdgeEditorPanelProps { interface EdgeEditorPanelProps {
@ -29,6 +31,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
const [relationType, setRelationType] = useState(''); const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState(''); const [relationLabel, setRelationLabel] = useState('');
const [relationLabels, setRelationLabels] = useState<string[]>([]); const [relationLabels, setRelationLabels] = useState<string[]>([]);
const [relationCitations, setRelationCitations] = useState<string[]>([]);
const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed'); const [relationDirectionality, setRelationDirectionality] = useState<EdgeDirectionality>('directed');
// Track if user has made changes // Track if user has made changes
@ -41,6 +44,9 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
// Label modal state // Label modal state
const [showLabelModal, setShowLabelModal] = useState(false); const [showLabelModal, setShowLabelModal] = useState(false);
// Bibliography modal state
const [showBibliographyModal, setShowBibliographyModal] = useState(false);
// Update state when selected edge changes // Update state when selected edge changes
useEffect(() => { useEffect(() => {
if (selectedEdge.data) { if (selectedEdge.data) {
@ -49,6 +55,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel; const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || ''); setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
setRelationLabels(selectedEdge.data.labels || []); setRelationLabels(selectedEdge.data.labels || []);
setRelationCitations(selectedEdge.data.citations || []);
const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type); const edgeTypeConfig = edgeTypes.find((et) => et.id === selectedEdge.data?.type);
setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed'); setRelationDirectionality(selectedEdge.data.directionality || edgeTypeConfig?.defaultDirectionality || 'directed');
setHasEdgeChanges(false); setHasEdgeChanges(false);
@ -63,9 +70,10 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
directionality: relationDirectionality, directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined, labels: relationLabels.length > 0 ? relationLabels : undefined,
citations: relationCitations.length > 0 ? relationCitations : undefined,
}); });
setHasEdgeChanges(false); setHasEdgeChanges(false);
}, [selectedEdge.id, relationType, relationLabel, relationDirectionality, relationLabels, hasEdgeChanges, updateEdge]); }, [selectedEdge.id, relationType, relationLabel, relationDirectionality, relationLabels, relationCitations, hasEdgeChanges, updateEdge]);
// Debounce live updates // Debounce live updates
useEffect(() => { useEffect(() => {
@ -187,6 +195,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
directionality: relationDirectionality, directionality: relationDirectionality,
labels: relationLabels.length > 0 ? relationLabels : undefined, labels: relationLabels.length > 0 ? relationLabels : undefined,
citations: relationCitations.length > 0 ? relationCitations : undefined,
}); });
}} }}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
@ -246,6 +255,21 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
/> />
</div> </div>
{/* Citations */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Citations (optional)
</label>
<CitationSelector
value={relationCitations}
onChange={(newCitations) => {
setRelationCitations(newCitations);
setHasEdgeChanges(true);
}}
onOpenBibliography={() => setShowBibliographyModal(true)}
/>
</div>
{/* Directionality */} {/* Directionality */}
<div className="pt-3 border-t border-gray-200"> <div className="pt-3 border-t border-gray-200">
<label className="block text-xs font-medium text-gray-700 mb-2"> <label className="block text-xs font-medium text-gray-700 mb-2">
@ -263,6 +287,7 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
label: relationLabel.trim() || undefined, label: relationLabel.trim() || undefined,
directionality: newValue, directionality: newValue,
labels: relationLabels.length > 0 ? relationLabels : undefined, labels: relationLabels.length > 0 ? relationLabels : undefined,
citations: relationCitations.length > 0 ? relationCitations : undefined,
}); });
} }
}} }}
@ -345,6 +370,10 @@ const EdgeEditorPanel = ({ selectedEdge, onClose }: EdgeEditorPanelProps) => {
isOpen={showLabelModal} isOpen={showLabelModal}
onClose={() => setShowLabelModal(false)} onClose={() => setShowLabelModal(false)}
/> />
<BibliographyConfigModal
isOpen={showBibliographyModal}
onClose={() => setShowBibliographyModal(false)}
/>
</> </>
); );
}; };

View file

@ -7,7 +7,9 @@ import { useConfirm } from '../../hooks/useConfirm';
import ConnectionDisplay from '../Common/ConnectionDisplay'; import ConnectionDisplay from '../Common/ConnectionDisplay';
import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import LabelConfigModal from '../Config/LabelConfig'; import LabelConfigModal from '../Config/LabelConfig';
import BibliographyConfigModal from '../Config/BibliographyConfig';
import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector'; import AutocompleteLabelSelector from '../Common/AutocompleteLabelSelector';
import CitationSelector from '../Common/CitationSelector';
import type { Actor } from '../../types'; import type { Actor } from '../../types';
interface NodeEditorPanelProps { interface NodeEditorPanelProps {
@ -24,6 +26,7 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
const [actorLabel, setActorLabel] = useState(''); const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState(''); const [actorDescription, setActorDescription] = useState('');
const [actorLabels, setActorLabels] = useState<string[]>([]); const [actorLabels, setActorLabels] = useState<string[]>([]);
const [actorCitations, setActorCitations] = useState<string[]>([]);
const labelInputRef = useRef<HTMLInputElement>(null); const labelInputRef = useRef<HTMLInputElement>(null);
// Track if user has made changes // Track if user has made changes
@ -36,12 +39,16 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
// Label modal state // Label modal state
const [showLabelModal, setShowLabelModal] = useState(false); const [showLabelModal, setShowLabelModal] = useState(false);
// Bibliography modal state
const [showBibliographyModal, setShowBibliographyModal] = useState(false);
// Update state when selected node changes // Update state when selected node changes
useEffect(() => { useEffect(() => {
setActorType(selectedNode.data?.type || ''); setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || ''); setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || ''); setActorDescription(selectedNode.data?.description || '');
setActorLabels(selectedNode.data?.labels || []); setActorLabels(selectedNode.data?.labels || []);
setActorCitations(selectedNode.data?.citations || []);
setHasNodeChanges(false); setHasNodeChanges(false);
// Focus and select the label input when node is selected // Focus and select the label input when node is selected
@ -62,10 +69,11 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
label: actorLabel, label: actorLabel,
description: actorDescription || undefined, description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined, labels: actorLabels.length > 0 ? actorLabels : undefined,
citations: actorCitations.length > 0 ? actorCitations : undefined,
}, },
}); });
setHasNodeChanges(false); setHasNodeChanges(false);
}, [selectedNode.id, actorType, actorLabel, actorDescription, actorLabels, hasNodeChanges, updateNode]); }, [selectedNode.id, actorType, actorLabel, actorDescription, actorLabels, actorCitations, hasNodeChanges, updateNode]);
// Debounce live updates // Debounce live updates
useEffect(() => { useEffect(() => {
@ -147,6 +155,7 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
label: actorLabel, label: actorLabel,
description: actorDescription || undefined, description: actorDescription || undefined,
labels: actorLabels.length > 0 ? actorLabels : undefined, labels: actorLabels.length > 0 ? actorLabels : undefined,
citations: actorCitations.length > 0 ? actorCitations : undefined,
}, },
}); });
}} }}
@ -232,6 +241,21 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
/> />
</div> </div>
{/* Citations */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Citations (optional)
</label>
<CitationSelector
value={actorCitations}
onChange={(newCitations) => {
setActorCitations(newCitations);
setHasNodeChanges(true);
}}
onOpenBibliography={() => setShowBibliographyModal(true)}
/>
</div>
{/* Connections */} {/* Connections */}
<div className="pt-3 border-t border-gray-200"> <div className="pt-3 border-t border-gray-200">
<h3 className="text-xs font-semibold text-gray-700 mb-2"> <h3 className="text-xs font-semibold text-gray-700 mb-2">
@ -314,6 +338,10 @@ const NodeEditorPanel = ({ selectedNode, onClose }: NodeEditorPanelProps) => {
isOpen={showLabelModal} isOpen={showLabelModal}
onClose={() => setShowLabelModal(false)} onClose={() => setShowLabelModal(false)}
/> />
<BibliographyConfigModal
isOpen={showBibliographyModal}
onClose={() => setShowBibliographyModal(false)}
/>
</> </>
); );
}; };

View file

@ -0,0 +1,81 @@
import { useGraphStore } from '../stores/graphStore';
import { useBibliographyStore } from '../stores/bibliographyStore';
import { useWorkspaceStore } from '../stores/workspaceStore';
import { useGraphWithHistory } from './useGraphWithHistory';
import { useDocumentHistory } from './useDocumentHistory';
import type { CSLReference } from '../types/bibliography';
/**
* Hook that wraps bibliography operations with history tracking
* Similar to useGraphWithHistory pattern
*/
export const useBibliographyWithHistory = () => {
const { addReference, updateReference, deleteReference } = useBibliographyStore();
const { pushToHistory } = useDocumentHistory();
const { activeDocumentId } = useWorkspaceStore();
const { nodes, edges } = useGraphStore();
const { updateNode, updateEdge } = useGraphWithHistory();
const addReferenceWithHistory = (ref: Partial<CSLReference>) => {
const id = addReference(ref);
pushToHistory(`Added reference: ${ref.title || 'Untitled'}`);
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId!);
return id;
};
const updateReferenceWithHistory = (id: string, updates: Partial<CSLReference>) => {
const ref = useBibliographyStore.getState().getReferenceById(id);
updateReference(id, updates);
pushToHistory(`Updated reference: ${ref?.title || 'Unknown'}`);
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId!);
};
const deleteReferenceWithHistory = (id: string) => {
const ref = useBibliographyStore.getState().getReferenceById(id);
// Remove citations from nodes and edges
nodes.forEach(node => {
if (node.data.citations?.includes(id)) {
updateNode(node.id, {
data: {
...node.data,
citations: node.data.citations.filter(cid => cid !== id),
},
});
}
});
edges.forEach(edge => {
if (edge.data?.citations?.includes(id)) {
updateEdge(edge.id, {
...edge.data,
citations: edge.data.citations.filter(cid => cid !== id),
});
}
});
deleteReference(id);
pushToHistory(`Deleted reference: ${ref?.title || 'Unknown'}`);
// Mark document as dirty
useWorkspaceStore.getState().markDocumentDirty(activeDocumentId!);
};
const getCitationCount = (referenceId: string) => {
const nodeCount = nodes.filter(n => n.data.citations?.includes(referenceId)).length;
const edgeCount = edges.filter(e => e.data?.citations?.includes(referenceId)).length;
return { nodes: nodeCount, edges: edgeCount };
};
return {
addReference: addReferenceWithHistory,
updateReference: updateReferenceWithHistory,
deleteReference: deleteReferenceWithHistory,
getCitationCount,
};
};

View file

@ -0,0 +1,302 @@
import { create } from 'zustand';
// @ts-expect-error - citation.js doesn't have TypeScript definitions
import { Cite } from '@citation-js/core';
// Load plugins
import '@citation-js/plugin-csl';
import '@citation-js/plugin-doi';
import '@citation-js/plugin-bibtex';
import '@citation-js/plugin-ris';
import type {
BibliographyReference,
CSLReference,
ReferenceAppMetadata,
BibliographySettings
} from '../types/bibliography';
interface BibliographyStore {
// State
citeInstance: Cite; // The citation.js instance
appMetadata: Record<string, ReferenceAppMetadata>; // App-specific data
settings: BibliographySettings;
// Getters
getReferences: () => BibliographyReference[]; // Merges CSL + app data
getReferenceById: (id: string) => BibliographyReference | undefined;
getCSLData: () => CSLReference[]; // Raw CSL-JSON from Cite
// CRUD operations (use citation.js methods)
addReference: (ref: Partial<CSLReference>) => string;
updateReference: (id: string, updates: Partial<CSLReference>) => void;
deleteReference: (id: string) => void;
duplicateReference: (id: string) => string;
// Bulk operations
setReferences: (refs: CSLReference[]) => void;
importReferences: (refs: CSLReference[]) => void;
clearAll: () => void;
// Metadata operations
updateMetadata: (id: string, metadata: Partial<ReferenceAppMetadata>) => void;
// Settings
setSettings: (settings: BibliographySettings) => void;
// Citation.js powered operations
formatReference: (id: string, style?: string, format?: 'html' | 'text') => string;
formatBibliography: (style?: string, format?: 'html' | 'text') => string;
parseInput: (input: string) => Promise<CSLReference[]>;
exportAs: (format: 'bibtex' | 'ris' | 'json') => string;
}
export const useBibliographyStore = create<BibliographyStore>((set, get) => ({
citeInstance: new Cite([]),
appMetadata: {},
settings: {
defaultStyle: 'apa',
sortOrder: 'author',
},
getReferences: () => {
const csl = get().citeInstance.data as CSLReference[];
const metadata = get().appMetadata;
// Merge CSL data with app metadata
return csl.map(ref => ({
...ref,
_app: metadata[ref.id],
}));
},
getReferenceById: (id) => {
const refs = get().getReferences();
return refs.find(ref => ref.id === id);
},
getCSLData: () => {
return get().citeInstance.data as CSLReference[];
},
addReference: (ref) => {
const { citeInstance } = get();
// Generate ID if not provided
const id = ref.id || `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const fullRef = { ...ref, id };
// Use citation.js add() method
citeInstance.add(fullRef);
// Add app metadata
const now = new Date().toISOString();
set(state => ({
appMetadata: {
...state.appMetadata,
[id]: {
id,
tags: [],
createdAt: now,
updatedAt: now,
},
},
}));
return id;
},
updateReference: (id, updates) => {
const { citeInstance } = get();
const data = citeInstance.data as CSLReference[];
// Find and update the reference
const updatedData = data.map(ref =>
ref.id === id ? { ...ref, ...updates } : ref
);
// Use citation.js set() method to replace all data
citeInstance.set(updatedData);
// Update metadata timestamp
set(state => ({
appMetadata: {
...state.appMetadata,
[id]: {
...state.appMetadata[id],
updatedAt: new Date().toISOString(),
},
},
}));
},
deleteReference: (id) => {
const { citeInstance } = get();
const data = citeInstance.data as CSLReference[];
// Remove from citation.js
const filteredData = data.filter(ref => ref.id !== id);
citeInstance.set(filteredData);
// Remove metadata
set(state => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [id]: _removed, ...rest } = state.appMetadata;
return { appMetadata: rest };
});
},
duplicateReference: (id) => {
const original = get().getReferenceById(id);
if (!original) return '';
const newId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const duplicate = {
...original,
id: newId,
title: `${original.title} (Copy)`,
};
// Remove _app before adding to Cite
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _app, ...cslData } = duplicate;
get().addReference(cslData);
return newId;
},
setReferences: (refs) => {
const { citeInstance } = get();
// Use citation.js set() to replace all references
citeInstance.set(refs);
// Initialize metadata for new references
const metadata: Record<string, ReferenceAppMetadata> = {};
const now = new Date().toISOString();
refs.forEach(ref => {
metadata[ref.id] = {
id: ref.id,
tags: [],
createdAt: now,
updatedAt: now,
};
});
set({ appMetadata: metadata });
},
importReferences: (refs) => {
const { citeInstance } = get();
// Use citation.js add() to append references
citeInstance.add(refs);
// Add metadata for new references
const now = new Date().toISOString();
set(state => {
const newMetadata = { ...state.appMetadata };
refs.forEach(ref => {
if (!newMetadata[ref.id]) {
newMetadata[ref.id] = {
id: ref.id,
tags: [],
createdAt: now,
updatedAt: now,
};
}
});
return { appMetadata: newMetadata };
});
},
clearAll: () => {
get().citeInstance.reset(); // citation.js method to clear all data
set({ appMetadata: {} });
},
updateMetadata: (id, updates) => {
set(state => ({
appMetadata: {
...state.appMetadata,
[id]: {
...state.appMetadata[id],
...updates,
updatedAt: new Date().toISOString(),
},
},
}));
},
setSettings: (settings) => set({ settings }),
formatReference: (id, style, format = 'html') => {
const { citeInstance, settings } = get();
const styleToUse = style || settings.defaultStyle;
// Find the reference
const ref = (citeInstance.data as CSLReference[]).find(r => r.id === id);
if (!ref) return '';
// Create temporary Cite instance for single reference
const cite = new Cite(ref);
// Use citation.js format() method
return cite.format('bibliography', {
format: format,
template: styleToUse,
lang: 'en-US',
});
},
formatBibliography: (style, format = 'html') => {
const { citeInstance, settings } = get();
const styleToUse = style || settings.defaultStyle;
// Use citation.js format() method on entire instance
return citeInstance.format('bibliography', {
format: format,
template: styleToUse,
lang: 'en-US',
});
},
parseInput: async (input) => {
try {
// Use citation.js async parsing
// This handles DOIs, URLs, BibTeX, RIS, etc. automatically
const cite = await Cite.async(input);
return cite.data as CSLReference[];
} catch (error) {
console.error('Failed to parse input:', error);
throw new Error('Could not parse citation data');
}
},
exportAs: (format) => {
const { citeInstance } = get();
// Use citation.js format() method
switch (format) {
case 'bibtex':
return citeInstance.format('bibtex');
case 'ris':
return citeInstance.format('ris');
case 'json':
return JSON.stringify(citeInstance.data, null, 2);
default:
return '';
}
},
}));
// Clear bibliography when switching documents
export const clearBibliographyForDocumentSwitch = () => {
useBibliographyStore.setState({
citeInstance: new Cite([]),
appMetadata: {},
settings: {
defaultStyle: 'apa',
sortOrder: 'author',
},
});
};

View file

@ -1,5 +1,6 @@
import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types'; import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig, LabelConfig } from '../../types';
import type { ConstellationState } from '../../types/timeline'; import type { ConstellationState } from '../../types/timeline';
import type { Bibliography } from '../../types/bibliography';
/** /**
* Persistence Types * Persistence Types
@ -44,6 +45,8 @@ export interface ConstellationDocument {
edgeTypes: EdgeTypeConfig[]; edgeTypes: EdgeTypeConfig[];
// Global labels for the entire document (optional for backward compatibility) // Global labels for the entire document (optional for backward compatibility)
labels?: LabelConfig[]; labels?: LabelConfig[];
// Global bibliography for the entire document (optional for backward compatibility)
bibliography?: Bibliography;
// Timeline with multiple states - every document has this // Timeline with multiple states - every document has this
// The graph is stored within each state (nodes and edges only, not types) // The graph is stored within each state (nodes and edges only, not types)
timeline: { timeline: {

View file

@ -26,8 +26,12 @@ import {
import { useToastStore } from './toastStore'; import { useToastStore } from './toastStore';
import { useTimelineStore } from './timelineStore'; import { useTimelineStore } from './timelineStore';
import { useGraphStore } from './graphStore'; import { useGraphStore } from './graphStore';
import { useBibliographyStore } from './bibliographyStore';
import type { ConstellationState, Timeline } from '../types/timeline'; import type { ConstellationState, Timeline } from '../types/timeline';
import { getCurrentGraphFromDocument } from './persistence/loader'; import { getCurrentGraphFromDocument } from './persistence/loader';
// @ts-expect-error - citation.js doesn't have TypeScript definitions
import { Cite } from '@citation-js/core';
import type { CSLReference } from '../types/bibliography';
/** /**
* Workspace Store * Workspace Store
@ -91,6 +95,19 @@ function initializeWorkspace(): Workspace {
if (doc.timeline) { if (doc.timeline) {
useTimelineStore.getState().loadTimeline(savedState.activeDocumentId, doc.timeline as unknown as Timeline); useTimelineStore.getState().loadTimeline(savedState.activeDocumentId, doc.timeline as unknown as Timeline);
} }
// Load bibliography into bibliographyStore
const bibliographyStore = useBibliographyStore.getState();
if (doc.bibliography) {
bibliographyStore.citeInstance = new Cite(doc.bibliography.references);
bibliographyStore.appMetadata = doc.bibliography.metadata;
bibliographyStore.settings = doc.bibliography.settings;
} else {
// Initialize empty bibliography if not present (backward compatibility)
bibliographyStore.citeInstance = new Cite([]);
bibliographyStore.appMetadata = {};
bibliographyStore.settings = { defaultStyle: 'apa', sortOrder: 'author' };
}
} }
} }
@ -144,6 +161,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
newDoc.metadata.documentId = documentId; newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title; newDoc.metadata.title = title;
newDoc.labels = []; // Initialize with empty labels newDoc.labels = []; // Initialize with empty labels
newDoc.bibliography = { // Initialize with empty bibliography
references: [],
metadata: {},
settings: { defaultStyle: 'apa', sortOrder: 'author' },
};
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
id: documentId, id: documentId,
@ -223,6 +245,11 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
newDoc.metadata.documentId = documentId; newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title; newDoc.metadata.title = title;
newDoc.labels = sourceDoc.labels || []; // Copy labels from source document newDoc.labels = sourceDoc.labels || []; // Copy labels from source document
newDoc.bibliography = { // Initialize with empty bibliography (don't copy bibliography from template)
references: [],
metadata: {},
settings: sourceDoc.bibliography?.settings || { defaultStyle: 'apa', sortOrder: 'author' },
};
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
id: documentId, id: documentId,
@ -291,6 +318,19 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline); useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
} }
// Load bibliography into bibliographyStore
const bibliographyStore = useBibliographyStore.getState();
if (doc.bibliography) {
bibliographyStore.citeInstance = new Cite(doc.bibliography.references);
bibliographyStore.appMetadata = doc.bibliography.metadata;
bibliographyStore.settings = doc.bibliography.settings;
} else {
// Initialize empty bibliography if not present (backward compatibility)
bibliographyStore.citeInstance = new Cite([]);
bibliographyStore.appMetadata = {};
bibliographyStore.settings = { defaultStyle: 'apa', sortOrder: 'author' };
}
set((state) => { set((state) => {
const newDocuments = new Map(state.documents); const newDocuments = new Map(state.documents);
newDocuments.set(documentId, doc); newDocuments.set(documentId, doc);
@ -459,6 +499,16 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}, },
// Deep copy bibliography to avoid shared references
bibliography: sourceDoc.bibliography ? {
references: JSON.parse(JSON.stringify(sourceDoc.bibliography.references)),
metadata: JSON.parse(JSON.stringify(sourceDoc.bibliography.metadata)),
settings: { ...sourceDoc.bibliography.settings },
} : {
references: [],
metadata: {},
settings: { defaultStyle: 'apa', sortOrder: 'author' },
},
}; };
const metadata: DocumentMetadata = { const metadata: DocumentMetadata = {
@ -574,6 +624,19 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
// This preserves all timeline states, not just the current one // This preserves all timeline states, not just the current one
useTimelineStore.getState().loadTimeline(documentId, importedDoc.timeline as unknown as Timeline); useTimelineStore.getState().loadTimeline(documentId, importedDoc.timeline as unknown as Timeline);
// Load bibliography into bibliographyStore
const bibliographyStore = useBibliographyStore.getState();
if (importedDoc.bibliography) {
bibliographyStore.citeInstance = new Cite(importedDoc.bibliography.references);
bibliographyStore.appMetadata = importedDoc.bibliography.metadata;
bibliographyStore.settings = importedDoc.bibliography.settings;
} else {
// Initialize empty bibliography if not present (backward compatibility)
bibliographyStore.citeInstance = new Cite([]);
bibliographyStore.appMetadata = {};
bibliographyStore.settings = { defaultStyle: 'apa', sortOrder: 'author' };
}
set((state) => { set((state) => {
const newDocuments = new Map(state.documents); const newDocuments = new Map(state.documents);
newDocuments.set(documentId, importedDoc); newDocuments.set(documentId, importedDoc);
@ -641,6 +704,14 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
}; };
} }
// Ensure bibliography is up-to-date before exporting
const bibliographyStore = useBibliographyStore.getState();
doc.bibliography = {
references: bibliographyStore.citeInstance.data as CSLReference[],
metadata: bibliographyStore.appMetadata,
settings: bibliographyStore.settings,
};
// Export the complete document with all timeline states // Export the complete document with all timeline states
exportDocumentToFile(doc); exportDocumentToFile(doc);
useToastStore.getState().showToast('Document exported successfully', 'success'); useToastStore.getState().showToast('Document exported successfully', 'success');
@ -740,6 +811,14 @@ export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get)
}; };
} }
// Save bibliography from bibliographyStore
const bibliographyStore = useBibliographyStore.getState();
doc.bibliography = {
references: bibliographyStore.citeInstance.data as CSLReference[],
metadata: bibliographyStore.appMetadata,
settings: bibliographyStore.settings,
};
saveDocumentToStorage(documentId, doc); saveDocumentToStorage(documentId, doc);
const metadata = state.documentMetadata.get(documentId); const metadata = state.documentMetadata.get(documentId);

100
src/types/bibliography.ts Normal file
View file

@ -0,0 +1,100 @@
/**
* Bibliography Types
*
* Type definitions for the bibliography/reference management system.
* Based on CSL-JSON format (Citation Style Language) for compatibility with citation.js
*/
// Standard CSL-JSON reference types
export type ReferenceType =
| 'article-journal'
| 'article-magazine'
| 'article-newspaper'
| 'book'
| 'chapter'
| 'paper-conference'
| 'report'
| 'thesis'
| 'webpage'
| 'interview'
| 'manuscript'
| 'personal_communication'
| 'entry-encyclopedia'
| 'entry-dictionary';
/**
* Standard CSL-JSON reference structure
* This is what citation.js expects and produces
*/
export interface CSLReference {
id: string;
type: ReferenceType;
title?: string;
author?: Array<{ family?: string; given?: string; literal?: string }>;
issued?: { 'date-parts': [[number, number?, number?]] };
'container-title'?: string;
publisher?: string;
'publisher-place'?: string;
volume?: string | number;
issue?: string | number;
page?: string;
DOI?: string;
ISBN?: string;
ISSN?: string;
URL?: string;
PMID?: string;
abstract?: string;
note?: string;
keyword?: string;
accessed?: { 'date-parts': [[number, number?, number?]] };
interviewer?: Array<{ family?: string; given?: string; literal?: string }>;
interviewee?: Array<{ family?: string; given?: string; literal?: string }>;
'container-author'?: Array<{ family?: string; given?: string; literal?: string }>;
'collection-title'?: string;
edition?: string | number;
genre?: string;
// CSL-JSON is extensible - allow additional fields
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
/**
* App-specific metadata (NOT part of CSL-JSON)
* Stored separately from the Cite instance
*/
export interface ReferenceAppMetadata {
id: string; // Matches CSL reference ID
tags?: string[];
favorite?: boolean;
color?: string;
createdAt: string;
updatedAt: string;
}
/**
* Combined reference for UI display
* Merges CSL data + app metadata
*/
export interface BibliographyReference extends CSLReference {
_app?: ReferenceAppMetadata;
}
/**
* Bibliography settings per document
*/
export interface BibliographySettings {
defaultStyle: string; // CSL style ID (e.g., "apa", "chicago")
sortOrder: 'author' | 'year' | 'title';
}
/**
* Complete bibliography data structure for persistence
*/
export interface Bibliography {
// CSL-JSON array (can be loaded directly into Cite)
references: CSLReference[];
// App-specific metadata (stored separately)
metadata: Record<string, ReferenceAppMetadata>;
// Settings
settings: BibliographySettings;
}

View file

@ -6,6 +6,7 @@ export interface ActorData {
type: string; type: string;
description?: string; description?: string;
labels?: string[]; // Array of LabelConfig IDs labels?: string[]; // Array of LabelConfig IDs
citations?: string[]; // Array of bibliography reference IDs
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@ -20,6 +21,7 @@ export interface RelationData {
directionality?: EdgeDirectionality; directionality?: EdgeDirectionality;
strength?: number; strength?: number;
labels?: string[]; // Array of LabelConfig IDs labels?: string[]; // Array of LabelConfig IDs
citations?: string[]; // Array of bibliography reference IDs
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }

View file

@ -0,0 +1,88 @@
// @ts-expect-error - citation.js doesn't have TypeScript definitions
import { Cite } from '@citation-js/core';
import type { CSLReference } from '../../types/bibliography';
/**
* Format a single reference using citation.js
*/
export const formatReference = (
ref: CSLReference,
style: string = 'apa',
format: 'html' | 'text' = 'text'
): string => {
try {
const cite = new Cite(ref);
return cite.format('bibliography', {
format,
template: style,
lang: 'en-US',
});
} catch (error) {
console.error('Formatting error:', error);
return `[Error formatting reference: ${ref.title}]`;
}
};
/**
* Format short citation for lists (Author, Year)
* Uses citation.js citation format
*/
export const formatShortCitation = (
ref: CSLReference,
style: string = 'apa'
): string => {
try {
const cite = new Cite(ref);
// Use citation format (in-text) instead of bibliography
return cite.format('citation', {
format: 'text',
template: style,
lang: 'en-US',
});
} catch (error) {
// Fallback to simple format
const author = ref.author?.[0];
const authorStr = author?.family || author?.literal || 'Unknown';
const year = ref.issued?.['date-parts']?.[0]?.[0] || 'n.d.';
return `${authorStr} (${year})`;
}
};
/**
* Format full bibliography for multiple references
*/
export const formatBibliography = (
refs: CSLReference[],
style: string = 'apa',
format: 'html' | 'text' = 'html'
): string => {
try {
const cite = new Cite(refs);
return cite.format('bibliography', {
format,
template: style,
lang: 'en-US',
});
} catch (error) {
console.error('Bibliography formatting error:', error);
return '[Error formatting bibliography]';
}
};
/**
* Get list of available citation styles
* Citation.js supports 10,000+ styles via CSL
*/
export const getAvailableStyles = (): Array<{ id: string; label: string }> => {
// Common styles for social sciences
return [
{ id: 'apa', label: 'APA 7th Edition' },
{ id: 'chicago-author-date', label: 'Chicago Author-Date' },
{ id: 'chicago-note-bibliography', label: 'Chicago Notes' },
{ id: 'mla', label: 'MLA 9th Edition' },
{ id: 'harvard1', label: 'Harvard' },
{ id: 'vancouver', label: 'Vancouver' },
{ id: 'american-sociological-association', label: 'ASA' },
{ id: 'american-political-science-association', label: 'APSA' },
];
};

View file

@ -0,0 +1,108 @@
// @ts-expect-error - citation.js doesn't have TypeScript definitions
import { Cite } from '@citation-js/core';
import type { CSLReference } from '../../types/bibliography';
/**
* Import from various formats using citation.js
*/
export const importFromFile = async (
content: string,
format: 'bibtex' | 'ris' | 'json'
): Promise<CSLReference[]> => {
try {
const cite = await Cite.async(content);
return cite.data as CSLReference[];
} catch (error) {
throw new Error(`Failed to import ${format}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Export to various formats using citation.js
*/
export const exportToFormat = (
refs: CSLReference[],
format: 'bibtex' | 'ris' | 'json'
): string => {
try {
const cite = new Cite(refs);
switch (format) {
case 'bibtex':
return cite.format('bibtex');
case 'ris':
return cite.format('ris');
case 'json':
return JSON.stringify(cite.data, null, 2);
default:
throw new Error(`Unsupported format: ${format}`);
}
} catch (error) {
throw new Error(`Failed to export: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Export formatted bibliography as HTML
*/
export const exportFormattedBibliography = (
refs: CSLReference[],
style: string = 'apa'
): string => {
try {
const cite = new Cite(refs);
const bibliography = cite.format('bibliography', {
format: 'html',
template: style,
lang: 'en-US',
});
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Bibliography</title>
<style>
body {
font-family: 'Times New Roman', serif;
max-width: 800px;
margin: 2em auto;
line-height: 1.6;
}
h1 { text-align: center; }
.csl-entry {
margin-bottom: 1em;
padding-left: 2em;
text-indent: -2em;
}
</style>
</head>
<body>
<h1>Bibliography</h1>
${bibliography}
</body>
</html>`;
} catch (error) {
throw new Error(`Failed to generate HTML: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Detect format of input string
*/
export const detectFormat = (content: string): 'bibtex' | 'ris' | 'json' | 'unknown' => {
const trimmed = content.trim();
if (trimmed.startsWith('@')) return 'bibtex';
if (trimmed.startsWith('TY -')) return 'ris';
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
try {
JSON.parse(trimmed);
return 'json';
} catch {
return 'unknown';
}
}
return 'unknown';
};

View file

@ -0,0 +1,56 @@
// @ts-expect-error - citation.js doesn't have TypeScript definitions
import { Cite } from '@citation-js/core';
import type { CSLReference } from '../../types/bibliography';
/**
* Parse any citation input using citation.js
* Handles: DOI, URL, BibTeX, RIS, ISBN, PubMed ID, etc.
*/
export const parseSmartInput = async (input: string): Promise<CSLReference[]> => {
try {
const cite = await Cite.async(input);
return cite.data as CSLReference[];
} catch (error) {
throw new Error(`Could not parse input: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
/**
* Check if input looks like it can be parsed
*/
export const isValidCitationInput = (input: string): boolean => {
const trimmed = input.trim();
// DOI patterns
if (/^(https?:\/\/)?(dx\.)?doi\.org\/10\.\d{4,}/i.test(trimmed)) return true;
if (/^10\.\d{4,}\/.+/.test(trimmed)) return true;
// URL patterns
if (/^https?:\/\/.+/.test(trimmed)) return true;
// BibTeX patterns
if (/^@\w+\{/.test(trimmed)) return true;
// PubMed ID
if (/^PMID:\s*\d+/i.test(trimmed)) return true;
// ISBN
if (/^ISBN[:\s]*[\d-]+/i.test(trimmed)) return true;
return false;
};
/**
* Get input type hint for user
*/
export const getInputTypeHint = (input: string): string => {
const trimmed = input.trim();
if (/^10\.\d{4,}\//.test(trimmed)) return 'DOI';
if (/^https?:\/\//.test(trimmed)) return 'URL';
if (/^@\w+\{/.test(trimmed)) return 'BibTeX';
if (/^PMID:/i.test(trimmed)) return 'PubMed ID';
if (/^ISBN/i.test(trimmed)) return 'ISBN';
return 'Unknown';
};