feat/chat-history-upgrade #2
@@ -690,10 +690,66 @@ def do_travel(session_id: str):
|
||||
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')
|
||||
@require_auth
|
||||
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()
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
@media (max-width: 700px) {
|
||||
.npc-modal-body {
|
||||
@@ -1805,3 +1825,152 @@
|
||||
.chat-history::-webkit-scrollbar-thumb:hover {
|
||||
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)
|
||||
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-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>
|
||||
</div>
|
||||
|
||||
{# Modal Body - Three Column Layout #}
|
||||
{# Modal Body - Uses shared content partial #}
|
||||
<div class="modal-body npc-modal-body npc-modal-body--three-col">
|
||||
{# 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>
|
||||
|
||||
{# 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>
|
||||
{% include 'game/partials/npc_chat_content.html' %}
|
||||
</div>
|
||||
|
||||
{# Modal Footer #}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{#
|
||||
NPCs Accordion Content
|
||||
Shows NPCs at current location with click to chat
|
||||
Uses responsive navigation: modals on desktop, full pages on mobile
|
||||
#}
|
||||
{% if npcs %}
|
||||
<div class="npc-list">
|
||||
{% for npc in npcs %}
|
||||
<div class="npc-item"
|
||||
hx-get="{{ url_for('game.npc_chat_modal', session_id=session_id, npc_id=npc.npc_id) }}"
|
||||
hx-target="#modal-container"
|
||||
hx-swap="innerHTML">
|
||||
data-npc-id="{{ npc.npc_id }}"
|
||||
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) }}')"
|
||||
style="cursor: pointer;">
|
||||
<div class="npc-name">{{ npc.name }}</div>
|
||||
<div class="npc-role">{{ npc.role }}</div>
|
||||
<div class="npc-appearance">{{ npc.appearance }}</div>
|
||||
|
||||
@@ -149,4 +149,7 @@ document.addEventListener('keydown', function(e) {
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Responsive Modal Navigation -->
|
||||
<script src="{{ url_for('static', filename='js/responsive-modals.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user