Files
Phillip Tarrant 61a42d3a77 feat(api,web): tier-based session limits and daily turn usage display
Backend Changes:
- Add tier-based max_sessions config (free: 1, basic: 2, premium: 3, elite: 5)
- Add DELETE /api/v1/sessions/{id} endpoint for hard session deletion
- Cascade delete chat messages when session is deleted
- Add GET /api/v1/usage endpoint for daily turn limit info
- Replace hardcoded TIER_LIMITS with config-based ai_calls_per_day
- Handle unlimited (-1) tier in rate limiter service

Frontend Changes:
- Add inline session delete buttons with HTMX on character list
- Add usage_display.html component showing remaining daily turns
- Display usage indicator on character list and game play pages
- Page refresh after session deletion to update UI state

Documentation:
- Update API_REFERENCE.md with new endpoints and tier limits
- Update API_TESTING.md with session endpoint examples
- Update SESSION_MANAGEMENT.md with tier-based limits

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:00:45 -06:00

497 lines
13 KiB
HTML

{% extends "base.html" %}
{% block title %}Your Characters - Code of Conquest{% endblock %}
{% block content %}
<div class="characters-container">
<div class="characters-header">
<div class="header-left">
<h1 class="page-title">Your Characters</h1>
<p class="page-subtitle">
{{ characters|length }} of {{ max_characters }} characters
<span class="tier-badge">{{ current_tier|upper }}</span>
{% include 'components/usage_display.html' %}
</p>
</div>
<div class="header-right">
{% if can_create %}
<a href="{{ url_for('character_views.create_origin') }}" class="btn btn-primary">
⚔️ Create New Character
</a>
{% else %}
<button class="btn btn-primary" disabled title="Character limit reached for {{ current_tier }} tier">
Character Limit Reached
</button>
{% endif %}
</div>
</div>
<div class="decorative-line"></div>
{% if characters %}
<div class="characters-grid">
{% for character in characters %}
<div class="character-card">
<div class="character-card-header">
<h3 class="character-name">{{ character.name }}</h3>
<span class="character-level">Level {{ character.level }}</span>
</div>
<div class="character-info">
<div class="info-row">
<span class="info-label">Class:</span>
<span class="info-value">{{ character.class_name or character.class|title }}</span>
</div>
<div class="info-row">
<span class="info-label">Origin:</span>
<span class="info-value">{{ character.origin|replace('_', ' ')|title }}</span>
</div>
<div class="info-row">
<span class="info-label">Gold:</span>
<span class="info-value gold">{{ character.gold }} 💰</span>
</div>
<div class="info-row">
<span class="info-label">Experience:</span>
<span class="info-value">{{ character.experience }}</span>
</div>
</div>
{# Sessions Section #}
<div class="character-sessions">
{% if character.sessions %}
<div class="sessions-header">
<span class="sessions-label">Active Sessions:</span>
</div>
<div class="sessions-list">
{% for sess in character.sessions[:3] %}
<div class="session-item" id="session-{{ sess.session_id }}">
<a href="{{ url_for('game.play_session', session_id=sess.session_id) }}" class="session-link">
<span class="session-turn">Turn {{ sess.turn_number or 0 }}</span>
<span class="session-status {{ sess.status or 'active' }}">{{ sess.status or 'active' }}</span>
</a>
<button
hx-delete="{{ url_for('character_views.delete_session', session_id=sess.session_id) }}"
hx-confirm="Delete this session? This action cannot be undone."
hx-target="#session-{{ sess.session_id }}"
hx-swap="outerHTML"
class="btn-delete-session"
title="Delete Session">
&times;
</button>
</div>
{% endfor %}
{% if character.sessions|length > 3 %}
<span class="sessions-more">+{{ character.sessions|length - 3 }} more</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="character-actions">
{# Primary Play Action #}
{% if character.sessions %}
<a href="{{ url_for('game.play_session', session_id=character.sessions[0].session_id) }}" class="btn btn-primary btn-sm">
Continue Playing
</a>
<form method="POST" action="{{ url_for('character_views.create_session', character_id=character.character_id) }}" style="display: inline;">
<button type="submit" class="btn btn-secondary btn-sm" title="Start a new adventure">
New Session
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('character_views.create_session', character_id=character.character_id) }}" style="display: inline;">
<button type="submit" class="btn btn-primary btn-sm">
Start Adventure
</button>
</form>
{% endif %}
{# Secondary Actions #}
<a href="{{ url_for('character_views.view_character', character_id=character.character_id) }}" class="btn btn-secondary btn-sm">
Details
</a>
<a href="{{ url_for('character_views.view_skills', character_id=character.character_id) }}" class="btn btn-secondary btn-sm">
Skills
</a>
<form method="POST" action="{{ url_for('character_views.delete_character', character_id=character.character_id) }}" onsubmit="return confirm('Are you sure you want to delete {{ character.name }}? This cannot be undone.');" style="display: inline;">
<button type="submit" class="btn btn-danger btn-sm">
Delete
</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">⚔️</div>
<h2 class="empty-title">No Characters Yet</h2>
<p class="empty-message">Begin your adventure by creating your first character!</p>
<a href="{{ url_for('character_views.create_origin') }}" class="btn btn-primary btn-lg">
Create Your First Character
</a>
</div>
{% endif %}
</div>
<style>
/* ===== CHARACTERS CONTAINER ===== */
.characters-container {
max-width: 1400px;
margin: 2rem auto;
padding: 2rem;
}
/* ===== HEADER ===== */
.characters-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 2rem;
}
.header-left {
flex: 1;
}
.page-subtitle {
display: flex;
align-items: center;
gap: 1rem;
}
.tier-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--accent-gold);
color: var(--bg-primary);
border-radius: 12px;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
}
/* ===== CHARACTERS GRID ===== */
.characters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
/* ===== CHARACTER CARDS ===== */
.character-card {
background: var(--bg-secondary);
border: 2px solid var(--border-primary);
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s ease;
}
.character-card:hover {
border-color: var(--accent-gold);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.character-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--border-primary);
}
.character-name {
font-family: var(--font-heading);
font-size: var(--text-xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 1px;
margin: 0;
}
.character-level {
padding: 0.25rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 12px;
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
/* ===== CHARACTER INFO ===== */
.character-info {
margin-bottom: 1rem;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--bg-input);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
}
.info-value {
font-size: var(--text-sm);
color: var(--text-primary);
}
.info-value.gold {
color: var(--accent-gold);
font-weight: 600;
}
/* ===== STATS COMPACT ===== */
.character-stats-compact {
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-input);
border-radius: 4px;
}
.stat-mini {
display: flex;
align-items: center;
gap: 0.75rem;
}
.stat-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
min-width: 30px;
}
.stat-bar {
flex: 1;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-primary);
}
.stat-fill {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent-red-light) 0%, var(--accent-green) 100%);
transition: width 0.3s ease;
}
.stat-text {
font-size: var(--text-xs);
color: var(--text-secondary);
font-family: var(--font-mono);
min-width: 60px;
text-align: right;
}
/* ===== CHARACTER SESSIONS ===== */
.character-sessions {
margin-bottom: 1rem;
min-height: 1rem;
}
.sessions-header {
margin-bottom: 0.5rem;
}
.sessions-label {
font-size: var(--text-sm);
color: var(--text-muted);
font-weight: 600;
}
.sessions-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.session-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: 4px;
text-decoration: none;
transition: all 0.2s ease;
}
.session-link:hover {
border-color: var(--accent-gold);
background: var(--bg-secondary);
}
.session-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.btn-delete-session {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: 1px solid var(--accent-red);
border-radius: 3px;
color: var(--accent-red);
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
line-height: 1;
}
.btn-delete-session:hover {
background: var(--accent-red);
color: var(--text-primary);
}
.session-turn {
font-size: var(--text-xs);
color: var(--text-primary);
font-weight: 600;
}
.session-status {
font-size: var(--text-xs);
padding: 0.15rem 0.4rem;
border-radius: 3px;
text-transform: uppercase;
font-weight: 600;
}
.session-status.active {
background: var(--accent-green);
color: var(--bg-primary);
}
.session-status.paused {
background: var(--accent-gold);
color: var(--bg-primary);
}
.session-status.ended {
background: var(--text-muted);
color: var(--bg-primary);
}
.sessions-more {
font-size: var(--text-xs);
color: var(--text-muted);
padding: 0.35rem 0.5rem;
}
/* ===== CHARACTER ACTIONS ===== */
.character-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: var(--text-sm);
}
.btn-danger {
background: transparent;
border: 2px solid var(--accent-red);
color: var(--accent-red);
}
.btn-danger:hover {
background: var(--accent-red);
color: var(--text-primary);
}
/* ===== EMPTY STATE ===== */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-secondary);
border: 2px dashed var(--border-primary);
border-radius: 8px;
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-title {
font-family: var(--font-heading);
font-size: var(--text-2xl);
color: var(--accent-gold);
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 1rem;
}
.empty-message {
font-size: var(--text-lg);
color: var(--text-secondary);
margin-bottom: 2rem;
}
.btn-lg {
padding: 1rem 2rem;
font-size: var(--text-lg);
}
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.characters-container {
padding: 1rem;
}
.characters-header {
flex-direction: column;
align-items: flex-start;
}
.header-right {
width: 100%;
}
.header-right .btn {
width: 100%;
}
.characters-grid {
grid-template-columns: 1fr;
}
.character-actions {
flex-direction: column;
}
.character-actions .btn {
width: 100%;
}
}
</style>
{% endblock %}