feat(web): implement responsive modal pattern for mobile-friendly NPC chat
- Add hybrid modal/page navigation based on screen size (1024px breakpoint) - Desktop (>1024px): Uses modal overlays for quick interactions - Mobile (≤1024px): Navigates to dedicated full pages for better UX - Extract shared NPC chat content into reusable partial template - Add responsive navigation JavaScript (responsive-modals.js) - Create dedicated NPC chat page route with back button navigation - Add mobile-optimized CSS with sticky header and chat input - Fix HTMX indicator errors by using htmx-indicator class pattern - Document responsive modal pattern for future features Addresses mobile UX issues: cramped space, nested scrolling, keyboard conflicts, and lack of native back button support in modals. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -690,10 +690,66 @@ def do_travel(session_id: str):
|
|||||||
return f'<div class="error">Travel failed: {e}</div>', 500
|
return f'<div class="error">Travel failed: {e}</div>', 500
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/npc/<npc_id>')
|
||||||
|
@require_auth
|
||||||
|
def npc_chat_page(session_id: str, npc_id: str):
|
||||||
|
"""
|
||||||
|
Dedicated NPC chat page (mobile-friendly full page view).
|
||||||
|
Used on mobile devices for better UX.
|
||||||
|
"""
|
||||||
|
client = get_api_client()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get session to find character
|
||||||
|
session_response = client.get(f'/api/v1/sessions/{session_id}')
|
||||||
|
session_data = session_response.get('result', {})
|
||||||
|
character_id = session_data.get('character_id')
|
||||||
|
|
||||||
|
# Get NPC details with relationship info
|
||||||
|
npc_response = client.get(f'/api/v1/npcs/{npc_id}?character_id={character_id}')
|
||||||
|
npc_data = npc_response.get('result', {})
|
||||||
|
|
||||||
|
npc = {
|
||||||
|
'npc_id': npc_data.get('npc_id'),
|
||||||
|
'name': npc_data.get('name'),
|
||||||
|
'role': npc_data.get('role'),
|
||||||
|
'appearance': npc_data.get('appearance', {}).get('brief', ''),
|
||||||
|
'tags': npc_data.get('tags', []),
|
||||||
|
'image_url': npc_data.get('image_url')
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get relationship info
|
||||||
|
interaction_summary = npc_data.get('interaction_summary', {})
|
||||||
|
relationship_level = interaction_summary.get('relationship_level', 50)
|
||||||
|
interaction_count = interaction_summary.get('interaction_count', 0)
|
||||||
|
|
||||||
|
# Conversation history would come from character's npc_interactions
|
||||||
|
# For now, we'll leave it empty - the API returns it in dialogue responses
|
||||||
|
conversation_history = []
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/npc_chat_page.html',
|
||||||
|
session_id=session_id,
|
||||||
|
npc=npc,
|
||||||
|
conversation_history=conversation_history,
|
||||||
|
relationship_level=relationship_level,
|
||||||
|
interaction_count=interaction_count
|
||||||
|
)
|
||||||
|
|
||||||
|
except APINotFoundError:
|
||||||
|
return render_template('errors/404.html', message="NPC not found"), 404
|
||||||
|
except APIError as e:
|
||||||
|
logger.error("failed_to_load_npc_chat_page", session_id=session_id, npc_id=npc_id, error=str(e))
|
||||||
|
return render_template('errors/500.html', message=f"Failed to load NPC: {e}"), 500
|
||||||
|
|
||||||
|
|
||||||
@game_bp.route('/session/<session_id>/npc/<npc_id>/chat')
|
@game_bp.route('/session/<session_id>/npc/<npc_id>/chat')
|
||||||
@require_auth
|
@require_auth
|
||||||
def npc_chat_modal(session_id: str, npc_id: str):
|
def npc_chat_modal(session_id: str, npc_id: str):
|
||||||
"""Get NPC chat modal with conversation history."""
|
"""
|
||||||
|
Get NPC chat modal with conversation history.
|
||||||
|
Used on desktop for modal overlay experience.
|
||||||
|
"""
|
||||||
client = get_api_client()
|
client = get_api_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
380
public_web/docs/RESPONSIVE_MODALS.md
Normal file
380
public_web/docs/RESPONSIVE_MODALS.md
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
# Responsive Modal Pattern
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This pattern provides an optimal UX for modal-like content by adapting to screen size:
|
||||||
|
- **Desktop (>1024px)**: Displays content in modal overlays
|
||||||
|
- **Mobile (≤1024px)**: Navigates to dedicated full pages
|
||||||
|
|
||||||
|
This addresses common mobile modal UX issues: cramped space, nested scrolling, keyboard conflicts, and lack of native back button support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to Use This Pattern
|
||||||
|
|
||||||
|
Use this pattern when:
|
||||||
|
- Content requires significant interaction (forms, chat, complex data)
|
||||||
|
- Mobile users need better scroll/keyboard handling
|
||||||
|
- The interaction benefits from full-screen on mobile
|
||||||
|
- Desktop users benefit from keeping context visible
|
||||||
|
|
||||||
|
**Don't use** for:
|
||||||
|
- Simple confirmations/alerts (use standard modals)
|
||||||
|
- Very brief interactions (dropdowns, tooltips)
|
||||||
|
- Content that must overlay the game state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### 1. Create Shared Content Partial
|
||||||
|
|
||||||
|
Extract the actual content into a reusable partial template that both the modal and page can use.
|
||||||
|
|
||||||
|
**File**: `templates/game/partials/[feature]_content.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Shared content partial #}
|
||||||
|
<div class="feature-container">
|
||||||
|
<!-- Your content here -->
|
||||||
|
<!-- This will be used by both modal and page -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Two Routes
|
||||||
|
|
||||||
|
Create both a modal route and a page route in your view:
|
||||||
|
|
||||||
|
**File**: `app/views/game_views.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@game_bp.route('/session/<session_id>/feature/<feature_id>')
|
||||||
|
@require_auth
|
||||||
|
def feature_page(session_id: str, feature_id: str):
|
||||||
|
"""
|
||||||
|
Dedicated page view (mobile-friendly).
|
||||||
|
Used on mobile devices for better UX.
|
||||||
|
"""
|
||||||
|
# Fetch data
|
||||||
|
data = get_feature_data(session_id, feature_id)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/feature_page.html',
|
||||||
|
session_id=session_id,
|
||||||
|
feature=data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@game_bp.route('/session/<session_id>/feature/<feature_id>/modal')
|
||||||
|
@require_auth
|
||||||
|
def feature_modal(session_id: str, feature_id: str):
|
||||||
|
"""
|
||||||
|
Modal view (desktop overlay).
|
||||||
|
Used on desktop for quick interactions.
|
||||||
|
"""
|
||||||
|
# Fetch same data
|
||||||
|
data = get_feature_data(session_id, feature_id)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'game/partials/feature_modal.html',
|
||||||
|
session_id=session_id,
|
||||||
|
feature=data
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Page Template
|
||||||
|
|
||||||
|
Create a dedicated page template with header, back button, and shared content.
|
||||||
|
|
||||||
|
**File**: `templates/game/feature_page.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ feature.name }} - Code of Conquest{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="feature-page">
|
||||||
|
{# Page Header with Back Button #}
|
||||||
|
<div class="feature-header">
|
||||||
|
<a href="{{ url_for('game.play_session', session_id=session_id) }}"
|
||||||
|
class="feature-back-btn">
|
||||||
|
<svg><!-- Back arrow icon --></svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
<h1 class="feature-title">{{ feature.name }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Include shared content #}
|
||||||
|
{% include 'game/partials/feature_content.html' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Add any feature-specific JavaScript here
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Update Modal Template
|
||||||
|
|
||||||
|
Update your modal template to use the shared content partial.
|
||||||
|
|
||||||
|
**File**: `templates/game/partials/feature_modal.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
{# Modal wrapper #}
|
||||||
|
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||||
|
<div class="modal-content modal-content--xl">
|
||||||
|
{# Modal Header #}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ feature.name }}</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Modal Body - Uses shared content #}
|
||||||
|
<div class="modal-body">
|
||||||
|
{% include 'game/partials/feature_content.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Modal Footer (optional) #}
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn--secondary" onclick="closeModal()">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Add Responsive Navigation
|
||||||
|
|
||||||
|
Update the trigger element to use responsive navigation JavaScript.
|
||||||
|
|
||||||
|
**File**: `templates/game/partials/sidebar_features.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="feature-item"
|
||||||
|
data-feature-id="{{ feature.id }}"
|
||||||
|
onclick="navigateResponsive(
|
||||||
|
event,
|
||||||
|
'{{ url_for('game.feature_page', session_id=session_id, feature_id=feature.id) }}',
|
||||||
|
'{{ url_for('game.feature_modal', session_id=session_id, feature_id=feature.id) }}'
|
||||||
|
)"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<div class="feature-name">{{ feature.name }}</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Add Mobile-Friendly CSS
|
||||||
|
|
||||||
|
Add CSS for the dedicated page with mobile optimizations.
|
||||||
|
|
||||||
|
**File**: `static/css/play.css`
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Feature Page */
|
||||||
|
.feature-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Header with Back Button */
|
||||||
|
.feature-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-bottom: 2px solid var(--play-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--play-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-back-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent-gold);
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive layout */
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
.feature-page .feature-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.feature-page .feature-container {
|
||||||
|
padding: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Include JavaScript
|
||||||
|
|
||||||
|
Ensure the responsive modals JavaScript is included in your play page template.
|
||||||
|
|
||||||
|
**File**: `templates/game/play.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% block scripts %}
|
||||||
|
<!-- Existing scripts -->
|
||||||
|
<script>
|
||||||
|
// Your existing JavaScript
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Responsive Modal Navigation -->
|
||||||
|
<script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript API
|
||||||
|
|
||||||
|
The responsive navigation is handled by `responsive-modals.js`:
|
||||||
|
|
||||||
|
### Functions
|
||||||
|
|
||||||
|
#### `navigateResponsive(event, pageUrl, modalUrl)`
|
||||||
|
|
||||||
|
Navigates responsively based on screen size.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `event` (Event): Click event (will be prevented)
|
||||||
|
- `pageUrl` (string): Full page URL for mobile navigation
|
||||||
|
- `modalUrl` (string): Modal content URL for desktop HTMX loading
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
navigateResponsive(
|
||||||
|
event,
|
||||||
|
'/game/session/123/npc/456', // Page route
|
||||||
|
'/game/session/123/npc/456/chat' // Modal route
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `isMobile()`
|
||||||
|
|
||||||
|
Returns true if viewport is ≤1024px.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```javascript
|
||||||
|
if (isMobile()) {
|
||||||
|
// Mobile-specific behavior
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breakpoint
|
||||||
|
|
||||||
|
The mobile/desktop breakpoint is **1024px** to match the CSS media query:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const MOBILE_BREAKPOINT = 1024;
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures consistent behavior between JavaScript navigation and CSS responsive layouts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: NPC Chat
|
||||||
|
|
||||||
|
See the NPC chat implementation for a complete working example:
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- Page: `/game/session/<session_id>/npc/<npc_id>` (public_web/app/views/game_views.py:695)
|
||||||
|
- Modal: `/game/session/<session_id>/npc/<npc_id>/chat` (public_web/app/views/game_views.py:746)
|
||||||
|
|
||||||
|
**Templates:**
|
||||||
|
- Shared content: `templates/game/partials/npc_chat_content.html`
|
||||||
|
- Page: `templates/game/npc_chat_page.html`
|
||||||
|
- Modal: `templates/game/partials/npc_chat_modal.html`
|
||||||
|
|
||||||
|
**Trigger:**
|
||||||
|
- `templates/game/partials/sidebar_npcs.html` (line 11)
|
||||||
|
|
||||||
|
**CSS:**
|
||||||
|
- Page styles: `static/css/play.css` (lines 1809-1956)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
When implementing this pattern, test:
|
||||||
|
|
||||||
|
- [ ] Desktop (>1024px): Modal opens correctly
|
||||||
|
- [ ] Desktop: Modal closes with X button, overlay click, and Escape key
|
||||||
|
- [ ] Desktop: Game screen remains visible behind modal
|
||||||
|
- [ ] Mobile (≤1024px): Navigates to dedicated page
|
||||||
|
- [ ] Mobile: Back button returns to game
|
||||||
|
- [ ] Mobile: Content scrolls naturally
|
||||||
|
- [ ] Mobile: Keyboard doesn't break layout
|
||||||
|
- [ ] Both: Shared content renders identically
|
||||||
|
- [ ] Both: HTMX interactions work correctly
|
||||||
|
- [ ] Resize: Behavior adapts when crossing breakpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Always share content**: Use a partial template to avoid duplication
|
||||||
|
2. **Keep routes parallel**: Use consistent URL patterns (`/feature` and `/feature/modal`)
|
||||||
|
3. **Test both views**: Ensure feature parity between modal and page
|
||||||
|
4. **Mobile-first CSS**: Design for mobile, enhance for desktop
|
||||||
|
5. **Consistent breakpoint**: Always use 1024px to match the JavaScript
|
||||||
|
6. **Document examples**: Link to working implementations in this doc
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
Potential enhancements to this pattern:
|
||||||
|
|
||||||
|
- [ ] Add transition animations for modal open/close
|
||||||
|
- [ ] Support deep linking to modals on desktop
|
||||||
|
- [ ] Add browser back button handling for desktop modals
|
||||||
|
- [ ] Support customizable breakpoints per feature
|
||||||
|
- [ ] Add analytics tracking for modal vs page usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **HTMX_PATTERNS.md** - HTMX integration patterns
|
||||||
|
- **TEMPLATES.md** - Template structure and conventions
|
||||||
|
- **TESTING.md** - Manual testing procedures
|
||||||
@@ -1699,6 +1699,26 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HTMX Loading Indicator */
|
||||||
|
.history-loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show loading indicator when HTMX is making a request */
|
||||||
|
.htmx-indicator.htmx-request .history-loading {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide other content while loading */
|
||||||
|
.htmx-indicator.htmx-request > *:not(.history-loading) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive NPC Modal */
|
/* Responsive NPC Modal */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.npc-modal-body {
|
.npc-modal-body {
|
||||||
@@ -1805,3 +1825,152 @@
|
|||||||
.chat-history::-webkit-scrollbar-thumb:hover {
|
.chat-history::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== NPC CHAT DEDICATED PAGE ===== */
|
||||||
|
/* Mobile-friendly full page view for NPC conversations */
|
||||||
|
|
||||||
|
.npc-chat-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Header with Back Button */
|
||||||
|
.npc-chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-bottom: 2px solid var(--play-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--play-border);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-back-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent-gold);
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-back-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Container - Full Height Layout */
|
||||||
|
.npc-chat-page .npc-chat-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Layout for NPC Chat Content */
|
||||||
|
/* Desktop: 3-column grid */
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
.npc-chat-page .npc-chat-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px 1fr 300px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: Stacked layout */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.npc-chat-page .npc-chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact profile on mobile */
|
||||||
|
.npc-chat-page .npc-profile {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-page .npc-portrait {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-page .npc-profile-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npc-chat-page .npc-relationship,
|
||||||
|
.npc-chat-page .npc-profile-tags,
|
||||||
|
.npc-chat-page .npc-interaction-stats {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conversation takes most of the vertical space */
|
||||||
|
.npc-chat-page .npc-conversation {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* History panel is collapsible on mobile */
|
||||||
|
.npc-chat-page .npc-history-panel {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-top: 1px solid var(--play-border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure chat history fills available space on page */
|
||||||
|
.npc-chat-page .chat-history {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix chat input to bottom on mobile */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.npc-chat-page .chat-input-form {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-top: 1px solid var(--play-border);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
81
public_web/static/js/responsive-modals.js
Normal file
81
public_web/static/js/responsive-modals.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Responsive Modal Navigation
|
||||||
|
*
|
||||||
|
* Provides smart navigation that uses modals on desktop (>1024px)
|
||||||
|
* and full page navigation on mobile (<=1024px) for better UX.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Instead of using hx-get directly on elements, use:
|
||||||
|
* onclick="navigateResponsive(event, '/page/url', '/modal/url')"
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Breakpoint for mobile vs desktop (matches CSS @media query)
|
||||||
|
const MOBILE_BREAKPOINT = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current viewport is mobile size
|
||||||
|
*/
|
||||||
|
function isMobile() {
|
||||||
|
return window.innerWidth <= MOBILE_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate responsively based on screen size
|
||||||
|
*
|
||||||
|
* @param {Event} event - Click event (will be prevented)
|
||||||
|
* @param {string} pageUrl - Full page URL for mobile
|
||||||
|
* @param {string} modalUrl - Modal content URL for desktop (HTMX)
|
||||||
|
*/
|
||||||
|
function navigateResponsive(event, pageUrl, modalUrl) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (isMobile()) {
|
||||||
|
// Mobile: Navigate to full page
|
||||||
|
window.location.href = pageUrl;
|
||||||
|
} else {
|
||||||
|
// Desktop: Load modal via HTMX
|
||||||
|
const target = event.currentTarget;
|
||||||
|
|
||||||
|
// Trigger HTMX request programmatically
|
||||||
|
htmx.ajax('GET', modalUrl, {
|
||||||
|
target: '#modal-container',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup responsive navigation for NPC items
|
||||||
|
* Call this after NPCs are loaded
|
||||||
|
*/
|
||||||
|
function setupNPCResponsiveNav(sessionId) {
|
||||||
|
document.querySelectorAll('.npc-item').forEach(item => {
|
||||||
|
const npcId = item.getAttribute('data-npc-id');
|
||||||
|
if (!npcId) return;
|
||||||
|
|
||||||
|
const pageUrl = `/game/session/${sessionId}/npc/${npcId}`;
|
||||||
|
const modalUrl = `/game/session/${sessionId}/npc/${npcId}/chat`;
|
||||||
|
|
||||||
|
// Remove HTMX attributes and add responsive navigation
|
||||||
|
item.removeAttribute('hx-get');
|
||||||
|
item.removeAttribute('hx-target');
|
||||||
|
item.removeAttribute('hx-swap');
|
||||||
|
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.onclick = (e) => navigateResponsive(e, pageUrl, modalUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle window resize to adapt navigation behavior
|
||||||
|
* Debounced to avoid excessive calls
|
||||||
|
*/
|
||||||
|
let resizeTimeout;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimeout);
|
||||||
|
resizeTimeout = setTimeout(() => {
|
||||||
|
// Re-setup navigation if needed
|
||||||
|
console.log('Viewport resized:', isMobile() ? 'Mobile' : 'Desktop');
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
44
public_web/templates/game/npc_chat_page.html
Normal file
44
public_web/templates/game/npc_chat_page.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ npc.name }} - Code of Conquest{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<!-- Play screen styles for NPC chat -->
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/play.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="npc-chat-page">
|
||||||
|
{# Page Header with Back Button #}
|
||||||
|
<div class="npc-chat-header">
|
||||||
|
<a href="{{ url_for('game.play_session', session_id=session_id) }}" class="npc-chat-back-btn">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</a>
|
||||||
|
<h1 class="npc-chat-title">{{ npc.name }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Include shared NPC chat content #}
|
||||||
|
{% include 'game/partials/npc_chat_content.html' %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Clear chat input after submission
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
|
if (e.target.closest('.chat-history')) {
|
||||||
|
const form = document.querySelector('.chat-input-form');
|
||||||
|
if (form) {
|
||||||
|
const input = form.querySelector('.chat-input');
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
120
public_web/templates/game/partials/npc_chat_content.html
Normal file
120
public_web/templates/game/partials/npc_chat_content.html
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
{#
|
||||||
|
NPC Chat Content (Shared Partial)
|
||||||
|
Used by both modal and dedicated page views
|
||||||
|
Displays NPC profile, conversation interface, and message history
|
||||||
|
#}
|
||||||
|
<div class="npc-chat-container">
|
||||||
|
{# Left Column: NPC Profile #}
|
||||||
|
<div class="npc-profile">
|
||||||
|
{# NPC Portrait #}
|
||||||
|
<div class="npc-portrait">
|
||||||
|
{% if npc.image_url %}
|
||||||
|
<img src="{{ npc.image_url }}" alt="{{ npc.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="npc-portrait-placeholder">
|
||||||
|
{{ npc.name[0] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# NPC Info #}
|
||||||
|
<div class="npc-profile-info">
|
||||||
|
<div class="npc-profile-role">{{ npc.role }}</div>
|
||||||
|
{% if npc.appearance %}
|
||||||
|
<div class="npc-profile-appearance">{{ npc.appearance }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# NPC Tags #}
|
||||||
|
{% if npc.tags %}
|
||||||
|
<div class="npc-profile-tags">
|
||||||
|
{% for tag in npc.tags %}
|
||||||
|
<span class="npc-profile-tag">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Relationship Meter #}
|
||||||
|
<div class="npc-relationship">
|
||||||
|
<div class="relationship-header">
|
||||||
|
<span class="relationship-label">Relationship</span>
|
||||||
|
<span class="relationship-value">{{ relationship_level|default(50) }}/100</span>
|
||||||
|
</div>
|
||||||
|
<div class="relationship-bar">
|
||||||
|
<div class="relationship-fill" style="width: {{ relationship_level|default(50) }}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relationship-status">
|
||||||
|
{% set level = relationship_level|default(50) %}
|
||||||
|
{% if level >= 80 %}
|
||||||
|
<span class="status-friendly">Trusted Ally</span>
|
||||||
|
{% elif level >= 60 %}
|
||||||
|
<span class="status-friendly">Friendly</span>
|
||||||
|
{% elif level >= 40 %}
|
||||||
|
<span class="status-neutral">Neutral</span>
|
||||||
|
{% elif level >= 20 %}
|
||||||
|
<span class="status-unfriendly">Wary</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-unfriendly">Hostile</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Interaction Stats #}
|
||||||
|
<div class="npc-interaction-stats">
|
||||||
|
<div class="interaction-stat">
|
||||||
|
<span class="stat-label">Conversations</span>
|
||||||
|
<span class="stat-value">{{ interaction_count|default(0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Center Column: Conversation #}
|
||||||
|
<div class="npc-conversation">
|
||||||
|
{# Conversation History #}
|
||||||
|
<div class="chat-history" id="chat-history-{{ npc.npc_id }}">
|
||||||
|
{% if conversation_history %}
|
||||||
|
{% for msg in conversation_history %}
|
||||||
|
<div class="chat-message chat-message--{{ msg.speaker }}">
|
||||||
|
<strong>{{ msg.speaker_name }}:</strong> {{ msg.text }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="chat-empty-state">
|
||||||
|
Start a conversation with {{ npc.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Chat Input #}
|
||||||
|
<form class="chat-input-form"
|
||||||
|
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
|
||||||
|
hx-target="#chat-history-{{ npc.npc_id }}"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
<input type="text"
|
||||||
|
name="player_response"
|
||||||
|
class="chat-input"
|
||||||
|
placeholder="Say something..."
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus>
|
||||||
|
<button type="submit" class="chat-send-btn">Send</button>
|
||||||
|
<button type="button"
|
||||||
|
class="chat-greet-btn"
|
||||||
|
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
|
||||||
|
hx-vals='{"topic": "greeting"}'
|
||||||
|
hx-target="#chat-history-{{ npc.npc_id }}"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Greet
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Right Column: Message History Sidebar #}
|
||||||
|
<aside class="npc-history-panel htmx-indicator"
|
||||||
|
id="npc-history-{{ npc.npc_id }}"
|
||||||
|
hx-get="{{ url_for('game.npc_chat_history', session_id=session_id, npc_id=npc.npc_id) }}"
|
||||||
|
hx-trigger="load, newMessage from:body"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
{# History loaded via HTMX #}
|
||||||
|
<div class="history-loading">Loading history...</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{#
|
{#
|
||||||
NPC Chat Modal (Expanded)
|
NPC Chat Modal (Expanded)
|
||||||
Shows NPC profile with portrait, relationship meter, and conversation interface
|
Shows NPC profile with portrait, relationship meter, and conversation interface
|
||||||
|
Uses shared content partial for consistency with dedicated page view
|
||||||
#}
|
#}
|
||||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||||
<div class="modal-content modal-content--xl">
|
<div class="modal-content modal-content--xl">
|
||||||
@@ -10,122 +11,9 @@ Shows NPC profile with portrait, relationship meter, and conversation interface
|
|||||||
<button class="modal-close" onclick="closeModal()">×</button>
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Modal Body - Three Column Layout #}
|
{# Modal Body - Uses shared content partial #}
|
||||||
<div class="modal-body npc-modal-body npc-modal-body--three-col">
|
<div class="modal-body npc-modal-body npc-modal-body--three-col">
|
||||||
{# Left Column: NPC Profile #}
|
{% include 'game/partials/npc_chat_content.html' %}
|
||||||
<div class="npc-profile">
|
|
||||||
{# NPC Portrait #}
|
|
||||||
<div class="npc-portrait">
|
|
||||||
{% if npc.image_url %}
|
|
||||||
<img src="{{ npc.image_url }}" alt="{{ npc.name }}">
|
|
||||||
{% else %}
|
|
||||||
<div class="npc-portrait-placeholder">
|
|
||||||
{{ npc.name[0] }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# NPC Info #}
|
|
||||||
<div class="npc-profile-info">
|
|
||||||
<div class="npc-profile-role">{{ npc.role }}</div>
|
|
||||||
{% if npc.appearance %}
|
|
||||||
<div class="npc-profile-appearance">{{ npc.appearance }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# NPC Tags #}
|
|
||||||
{% if npc.tags %}
|
|
||||||
<div class="npc-profile-tags">
|
|
||||||
{% for tag in npc.tags %}
|
|
||||||
<span class="npc-profile-tag">{{ tag }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Relationship Meter #}
|
|
||||||
<div class="npc-relationship">
|
|
||||||
<div class="relationship-header">
|
|
||||||
<span class="relationship-label">Relationship</span>
|
|
||||||
<span class="relationship-value">{{ relationship_level|default(50) }}/100</span>
|
|
||||||
</div>
|
|
||||||
<div class="relationship-bar">
|
|
||||||
<div class="relationship-fill" style="width: {{ relationship_level|default(50) }}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="relationship-status">
|
|
||||||
{% set level = relationship_level|default(50) %}
|
|
||||||
{% if level >= 80 %}
|
|
||||||
<span class="status-friendly">Trusted Ally</span>
|
|
||||||
{% elif level >= 60 %}
|
|
||||||
<span class="status-friendly">Friendly</span>
|
|
||||||
{% elif level >= 40 %}
|
|
||||||
<span class="status-neutral">Neutral</span>
|
|
||||||
{% elif level >= 20 %}
|
|
||||||
<span class="status-unfriendly">Wary</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="status-unfriendly">Hostile</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Interaction Stats #}
|
|
||||||
<div class="npc-interaction-stats">
|
|
||||||
<div class="interaction-stat">
|
|
||||||
<span class="stat-label">Conversations</span>
|
|
||||||
<span class="stat-value">{{ interaction_count|default(0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Right Column: Conversation #}
|
|
||||||
<div class="npc-conversation">
|
|
||||||
{# Conversation History #}
|
|
||||||
<div class="chat-history" id="chat-history-{{ npc.npc_id }}">
|
|
||||||
{% if conversation_history %}
|
|
||||||
{% for msg in conversation_history %}
|
|
||||||
<div class="chat-message chat-message--{{ msg.speaker }}">
|
|
||||||
<strong>{{ msg.speaker_name }}:</strong> {{ msg.text }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="chat-empty-state">
|
|
||||||
Start a conversation with {{ npc.name }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Chat Input #}
|
|
||||||
<form class="chat-input-form"
|
|
||||||
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
|
|
||||||
hx-target="#chat-history-{{ npc.npc_id }}"
|
|
||||||
hx-swap="beforeend">
|
|
||||||
<input type="text"
|
|
||||||
name="player_response"
|
|
||||||
class="chat-input"
|
|
||||||
placeholder="Say something..."
|
|
||||||
autocomplete="off"
|
|
||||||
autofocus>
|
|
||||||
<button type="submit" class="chat-send-btn">Send</button>
|
|
||||||
<button type="button"
|
|
||||||
class="chat-greet-btn"
|
|
||||||
hx-post="{{ url_for('game.talk_to_npc', session_id=session_id, npc_id=npc.npc_id) }}"
|
|
||||||
hx-vals='{"topic": "greeting"}'
|
|
||||||
hx-target="#chat-history-{{ npc.npc_id }}"
|
|
||||||
hx-swap="beforeend">
|
|
||||||
Greet
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Right Column: Message History Sidebar #}
|
|
||||||
<aside class="npc-history-panel"
|
|
||||||
id="npc-history-{{ npc.npc_id }}"
|
|
||||||
hx-get="{{ url_for('game.npc_chat_history', session_id=session_id, npc_id=npc.npc_id) }}"
|
|
||||||
hx-trigger="load, newMessage from:body"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-indicator=".history-loading">
|
|
||||||
{# History loaded via HTMX #}
|
|
||||||
<div class="loading-state-small history-loading">Loading history...</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Modal Footer #}
|
{# Modal Footer #}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
{#
|
{#
|
||||||
NPCs Accordion Content
|
NPCs Accordion Content
|
||||||
Shows NPCs at current location with click to chat
|
Shows NPCs at current location with click to chat
|
||||||
|
Uses responsive navigation: modals on desktop, full pages on mobile
|
||||||
#}
|
#}
|
||||||
{% if npcs %}
|
{% if npcs %}
|
||||||
<div class="npc-list">
|
<div class="npc-list">
|
||||||
{% for npc in npcs %}
|
{% for npc in npcs %}
|
||||||
<div class="npc-item"
|
<div class="npc-item"
|
||||||
hx-get="{{ url_for('game.npc_chat_modal', session_id=session_id, npc_id=npc.npc_id) }}"
|
data-npc-id="{{ npc.npc_id }}"
|
||||||
hx-target="#modal-container"
|
onclick="navigateResponsive(event, '{{ url_for('game.npc_chat_page', session_id=session_id, npc_id=npc.npc_id) }}', '{{ url_for('game.npc_chat_modal', session_id=session_id, npc_id=npc.npc_id) }}')"
|
||||||
hx-swap="innerHTML">
|
style="cursor: pointer;">
|
||||||
<div class="npc-name">{{ npc.name }}</div>
|
<div class="npc-name">{{ npc.name }}</div>
|
||||||
<div class="npc-role">{{ npc.role }}</div>
|
<div class="npc-role">{{ npc.role }}</div>
|
||||||
<div class="npc-appearance">{{ npc.appearance }}</div>
|
<div class="npc-appearance">{{ npc.appearance }}</div>
|
||||||
|
|||||||
@@ -149,4 +149,7 @@ document.addEventListener('keydown', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Responsive Modal Navigation -->
|
||||||
|
<script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user