Compare commits

...

2 Commits

Author SHA1 Message Date
Miguel Astor
15a8072804 Unify modern templates into single template with style system
- Create templates/modern.html as unified base for brutalism, glassmorphism, neumorphism
- Add styles.py with CSS and chart config for each style
- Add --style argument to generate_report.py (overrides --template)
- Remove individual brutalism.html, glassmorphism.html, neumorphism.html
- Keep platinum.html separate due to unique Mac OS 9 structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:23:43 -04:00
Miguel Astor
31e8d152ae Add By Runner tab to summaries section in all templates
Extract runner field from Lutris database and display playtime
grouped by runner (wine, linux, steam, dosbox, etc.) in a new
third tab alongside Top Games and By Category.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-03 03:09:29 -04:00
7 changed files with 2255 additions and 3130 deletions

View File

@@ -13,9 +13,21 @@ Generate report with defaults:
python generate_report.py
```
Generate report with custom options:
Generate report with modern style:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png --template templates/platinum.html
python generate_report.py --style glassmorphism --output report.html
python generate_report.py --style brutalism --output report.html
python generate_report.py --style neumorphism --output report.html
```
Generate report with legacy Platinum template:
```bash
python generate_report.py --template templates/platinum.html --output report.html
```
All options:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png --style glassmorphism
```
## Architecture
@@ -26,13 +38,16 @@ python generate_report.py --db pga.db --output report.html --top 10 --background
- Loads HTML template from `templates/` folder (default: `templates/platinum.html`)
**HTML templates (`templates/`):**
- **platinum.html**: Mac OS 9 Platinum visual style
- **brutalism.html**: Bold industrial brutalist design with hard shadows
- **glassmorphism.html**: Modern frosted glass effect
- **neumorphism.html**: Soft 3D neumorphic style
- **modern.html**: Unified template for modern styles (brutalism, glassmorphism, neumorphism)
- **platinum.html**: Legacy Mac OS 9 Platinum visual style (separate template due to unique structure)
- All templates use Chart.js doughnut charts and dynamic JavaScript filtering
- Placeholder tokens like `__ALL_GAMES__`, `__BACKGROUND_IMAGE__` are replaced at generation time
- Support light/dark mode with theme toggle button
- Modern templates support light/dark/auto theme toggle button
**Style system (`styles.py`):**
- CSS definitions for each modern style (brutalism, glassmorphism, neumorphism)
- Theme configurations (colors, fonts, chart options) injected via `__THEME_CSS__` and `__THEME_CONFIG__`
- Use `--style` argument to select a modern style instead of `--template`
**Database schema (`schema.py`):**
- Reference file documenting Lutris database structure

View File

@@ -21,9 +21,14 @@ import json
import sqlite3
from pathlib import Path
from styles import get_theme_css, get_theme_config
# Directory where this script is located (for finding template.html)
SCRIPT_DIR = Path(__file__).parent
# Modern styles that use the unified template
MODERN_STYLES = ["brutalism", "glassmorphism", "neumorphism"]
def load_template(template_file: str) -> str:
"""Load the HTML template from the specified file."""
@@ -49,7 +54,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
total_library = cursor.fetchone()[0]
cursor.execute("""
SELECT id, name, playtime, COALESCE(service, 'local') as service
SELECT id, name, playtime, COALESCE(service, 'local') as service, COALESCE(runner, 'unknown') as runner
FROM games
WHERE playtime > 0
ORDER BY playtime DESC
@@ -75,6 +80,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
"name": row[1],
"playtime": row[2],
"service": row[3],
"runner": row[4],
"categories": game_categories.get(row[0], [])
}
for row in games_rows
@@ -82,7 +88,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
return games, total_library
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None) -> None:
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None, style: str = None) -> None:
"""Generate the HTML report."""
all_games, total_library = get_all_games(db_path)
@@ -103,50 +109,67 @@ def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str,
background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png")
background_image_custom = "none" # For templates that prefer no default background
# Load fonts
font_charcoal = load_asset_as_base64(assets_path / "Charcoal.ttf", "font/truetype")
font_monaco = load_asset_as_base64(assets_path / "MONACO.TTF", "font/truetype")
# Check if using modern unified template
if style and style in MODERN_STYLES:
html = load_template("templates/modern.html")
# Load images
titlebar_bg = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
title_stripes = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
close_btn = load_asset_as_base64(assets_path / "Windows" / "close-active.png", "image/png")
hide_btn = load_asset_as_base64(assets_path / "Windows" / "maximize-active.png", "image/png")
shade_btn = load_asset_as_base64(assets_path / "Windows" / "shade-active.png", "image/png")
check_off = load_asset_as_base64(assets_path / "Check-Radio" / "check-normal.png", "image/png")
check_on = load_asset_as_base64(assets_path / "Check-Radio" / "check-active.png", "image/png")
# Inject theme CSS and config
theme_css = get_theme_css(style)
theme_config = get_theme_config(style)
# Load scrollbar images
scrollbar_trough_v = load_asset_as_base64(assets_path / "Scrollbars" / "trough-scrollbar-vert.png", "image/png")
scrollbar_thumb_v = load_asset_as_base64(assets_path / "Scrollbars" / "slider-vertical.png", "image/png")
scrollbar_up = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-up.png", "image/png")
scrollbar_down = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-down.png", "image/png")
html = html.replace("__THEME_CSS__", theme_css)
html = html.replace("__THEME_CONFIG__", theme_config)
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
html = html.replace("__TOP_N__", str(top_n))
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
else:
# Legacy template handling (platinum and others)
# Load fonts
font_charcoal = load_asset_as_base64(assets_path / "Charcoal.ttf", "font/truetype")
font_monaco = load_asset_as_base64(assets_path / "MONACO.TTF", "font/truetype")
# Load tab images
tab_active = load_asset_as_base64(assets_path / "Tabs" / "tab-top-active.png", "image/png")
tab_inactive = load_asset_as_base64(assets_path / "Tabs" / "tab-top.png", "image/png")
# Load images
titlebar_bg = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
title_stripes = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
close_btn = load_asset_as_base64(assets_path / "Windows" / "close-active.png", "image/png")
hide_btn = load_asset_as_base64(assets_path / "Windows" / "maximize-active.png", "image/png")
shade_btn = load_asset_as_base64(assets_path / "Windows" / "shade-active.png", "image/png")
check_off = load_asset_as_base64(assets_path / "Check-Radio" / "check-normal.png", "image/png")
check_on = load_asset_as_base64(assets_path / "Check-Radio" / "check-active.png", "image/png")
html = load_template(template_file)
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
html = html.replace("__TOP_N__", str(top_n))
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__FONT_CHARCOAL__", font_charcoal)
html = html.replace("__FONT_MONACO__", font_monaco)
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
html = html.replace("__TITLEBAR_BG__", titlebar_bg)
html = html.replace("__TITLE_STRIPES__", title_stripes)
html = html.replace("__CLOSE_BTN__", close_btn)
html = html.replace("__HIDE_BTN__", hide_btn)
html = html.replace("__SHADE_BTN__", shade_btn)
html = html.replace("__CHECK_OFF__", check_off)
html = html.replace("__CHECK_ON__", check_on)
html = html.replace("__SCROLLBAR_TROUGH_V__", scrollbar_trough_v)
html = html.replace("__SCROLLBAR_THUMB_V__", scrollbar_thumb_v)
html = html.replace("__SCROLLBAR_UP__", scrollbar_up)
html = html.replace("__SCROLLBAR_DOWN__", scrollbar_down)
html = html.replace("__TAB_ACTIVE__", tab_active)
html = html.replace("__TAB_INACTIVE__", tab_inactive)
# Load scrollbar images
scrollbar_trough_v = load_asset_as_base64(assets_path / "Scrollbars" / "trough-scrollbar-vert.png", "image/png")
scrollbar_thumb_v = load_asset_as_base64(assets_path / "Scrollbars" / "slider-vertical.png", "image/png")
scrollbar_up = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-up.png", "image/png")
scrollbar_down = load_asset_as_base64(assets_path / "Scrollbars" / "stepper-down.png", "image/png")
# Load tab images
tab_active = load_asset_as_base64(assets_path / "Tabs" / "tab-top-active.png", "image/png")
tab_inactive = load_asset_as_base64(assets_path / "Tabs" / "tab-top.png", "image/png")
html = load_template(template_file)
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
html = html.replace("__TOP_N__", str(top_n))
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__FONT_CHARCOAL__", font_charcoal)
html = html.replace("__FONT_MONACO__", font_monaco)
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
html = html.replace("__TITLEBAR_BG__", titlebar_bg)
html = html.replace("__TITLE_STRIPES__", title_stripes)
html = html.replace("__CLOSE_BTN__", close_btn)
html = html.replace("__HIDE_BTN__", hide_btn)
html = html.replace("__SHADE_BTN__", shade_btn)
html = html.replace("__CHECK_OFF__", check_off)
html = html.replace("__CHECK_ON__", check_on)
html = html.replace("__SCROLLBAR_TROUGH_V__", scrollbar_trough_v)
html = html.replace("__SCROLLBAR_THUMB_V__", scrollbar_thumb_v)
html = html.replace("__SCROLLBAR_UP__", scrollbar_up)
html = html.replace("__SCROLLBAR_DOWN__", scrollbar_down)
html = html.replace("__TAB_ACTIVE__", tab_active)
html = html.replace("__TAB_INACTIVE__", tab_inactive)
Path(output_path).write_text(html, encoding="utf-8")
print(f"Report generated: {output_path}")
@@ -188,7 +211,13 @@ def main():
parser.add_argument(
"--template",
default="templates/platinum.html",
help="HTML template file to use (default: templates/platinum.html)"
help="HTML template file to use (default: templates/platinum.html). Ignored if --style is used."
)
parser.add_argument(
"--style",
choices=["brutalism", "glassmorphism", "neumorphism"],
default=None,
help="Modern style to use (brutalism, glassmorphism, neumorphism). Overrides --template."
)
args = parser.parse_args()
@@ -201,12 +230,17 @@ def main():
print(f"Error: Assets directory not found: {args.assets}")
return 1
template_path = SCRIPT_DIR / args.template
# Validate template only if not using modern style
if args.style and args.style in MODERN_STYLES:
template_path = SCRIPT_DIR / "templates" / "modern.html"
else:
template_path = SCRIPT_DIR / args.template
if not template_path.exists():
print(f"Error: Template file not found: {template_path}")
return 1
generate_report(args.db, args.output, args.top, args.assets, args.template, args.background)
generate_report(args.db, args.output, args.top, args.assets, args.template, args.background, args.style)
return 0

1903
styles.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,578 +6,8 @@
<title>Lutris Playtime Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg-primary: rgba(255, 255, 255, 0.25);
--bg-secondary: rgba(255, 255, 255, 0.15);
--bg-tertiary: rgba(255, 255, 255, 0.1);
--text-primary: #1a1a2e;
--text-secondary: #4a4a6a;
--text-muted: #6a6a8a;
--border-color: rgba(255, 255, 255, 0.3);
--shadow-color: rgba(0, 0, 0, 0.1);
--accent-color: #6366f1;
--accent-hover: #4f46e5;
--selection-bg: rgba(99, 102, 241, 0.3);
--glass-blur: 20px;
--card-radius: 16px;
}
[data-theme="dark"] {
--bg-primary: rgba(30, 30, 50, 0.6);
--bg-secondary: rgba(40, 40, 70, 0.5);
--bg-tertiary: rgba(50, 50, 80, 0.4);
--text-primary: #f0f0f5;
--text-secondary: #c0c0d0;
--text-muted: #9090a0;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.3);
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: rgba(30, 30, 50, 0.6);
--bg-secondary: rgba(40, 40, 70, 0.5);
--bg-tertiary: rgba(50, 50, 80, 0.4);
--text-primary: #f0f0f5;
--text-secondary: #c0c0d0;
--text-muted: #9090a0;
--border-color: rgba(255, 255, 255, 0.1);
--shadow-color: rgba(0, 0, 0, 0.3);
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #667eea;
background-image: url('__BACKGROUND_IMAGE__');
background-size: cover;
background-position: center;
background-attachment: fixed;
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow: 0 8px 32px var(--shadow-color);
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.1);
background: var(--bg-secondary);
}
.theme-toggle .icon {
transition: transform 0.3s ease;
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Glass Card Style */
.glass-card {
background: var(--bg-primary);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border-radius: var(--card-radius);
border: 1px solid var(--border-color);
box-shadow: 0 8px 32px var(--shadow-color);
margin-bottom: 24px;
overflow: hidden;
transition: all 0.3s ease;
}
.glass-card:hover {
box-shadow: 0 12px 40px var(--shadow-color);
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.card-content {
padding: 20px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 8px 16px;
border-radius: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
transition: all 0.2s ease;
}
.filter-label:hover {
background: var(--bg-tertiary);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid var(--text-muted);
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
border-color: var(--accent-color);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
font-weight: 500;
color: var(--text-primary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 12px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 20px 32px;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
min-width: 140px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Charts */
.charts-wrapper {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 16px;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
}
.chart-title {
font-size: 14px;
font-weight: 600;
text-align: center;
margin-bottom: 12px;
color: var(--text-primary);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
padding: 0 20px;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
border-radius: 12px 12px 0 0;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-bottom: none;
transition: all 0.2s ease;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-secondary);
border-color: var(--border-color);
}
.tab-content {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0 12px 12px 12px;
padding: 16px;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border-color);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
vertical-align: middle;
border-radius: 3px;
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: 12px;
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
font-weight: 500;
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
background: rgba(99, 102, 241, 0.2);
border-radius: 12px;
color: var(--accent-color);
margin-left: 4px;
text-transform: capitalize;
font-weight: 500;
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--text-muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s ease;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 12px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 22px;
}
.filter-label {
padding: 6px 12px;
font-size: 13px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
}
.theme-toggle {
width: 40px;
height: 40px;
font-size: 16px;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-primary);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 32px var(--shadow-color);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: scale(1.1);
background: var(--bg-secondary);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid var(--text-primary);
}
@media (max-width: 600px) {
.scroll-top {
width: 40px;
height: 40px;
}
.scroll-top-arrow {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
}
}
/* Theme-specific CSS injected here */
__THEME_CSS__
</style>
</head>
<body>
@@ -591,7 +21,7 @@
<span class="icon" id="theme-icon"></span>
</button>
<div class="glass-card">
<div class="card">
<div class="card-header">
<h1 class="card-title">Lutris Playtime Report</h1>
</div>
@@ -600,7 +30,7 @@
</div>
</div>
<div class="glass-card">
<div class="card">
<div class="card-header">
<h2 class="card-title">Statistics</h2>
</div>
@@ -622,7 +52,7 @@
</div>
</div>
<div class="glass-card">
<div class="card">
<div class="card-header">
<h2 class="card-title">Playtime Distribution</h2>
</div>
@@ -640,14 +70,15 @@
</div>
</div>
<div class="glass-card">
<div class="card">
<div class="card-header">
<h2 class="card-title">Summaries</h2>
</div>
<div class="card-content" style="padding: 0; padding-top: 16px;">
<div class="card-content summaries-content">
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -680,6 +111,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -702,11 +148,11 @@
themeIcon.className = 'icon icon-auto';
themeToggle.title = 'Theme: Auto (click to change)';
} else if (theme === 'light') {
themeIcon.textContent = '☀️';
themeIcon.textContent = '\u2600\uFE0F';
themeIcon.className = 'icon';
themeToggle.title = 'Theme: Light (click to change)';
} else {
themeIcon.textContent = '🌙';
themeIcon.textContent = '\uD83C\uDF19';
themeIcon.className = 'icon';
themeToggle.title = 'Theme: Dark (click to change)';
}
@@ -749,11 +195,8 @@
const allGames = __ALL_GAMES__;
const topN = __TOP_N__;
const colors = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#f97316',
'#eab308', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6',
'#64748b'
];
// Theme-specific configuration
const themeConfig = __THEME_CONFIG__;
function formatTime(hours) {
const h = Math.floor(hours);
@@ -857,14 +300,33 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
const runnerMap = {};
filtered.forEach(g => {
const runner = g.runner || 'unknown';
if (!runnerMap[runner]) {
runnerMap[runner] = { name: runner, playtime: 0, gameCount: 0 };
}
runnerMap[runner].playtime += g.playtime;
runnerMap[runner].gameCount++;
});
const runnersData = Object.values(runnerMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, totalPlaytime, totalGames };
}
function getChartTextColor() {
const theme = themes[currentThemeIndex];
if (theme === 'dark') return '#f0f0f5';
if (theme === 'light') return '#1a1a2e';
return getSystemTheme() === 'dark' ? '#f0f0f5' : '#1a1a2e';
if (theme === 'dark') return themeConfig.textColorDark;
if (theme === 'light') return themeConfig.textColorLight;
return getSystemTheme() === 'dark' ? themeConfig.textColorDark : themeConfig.textColorLight;
}
function getChartBorderColor() {
const theme = themes[currentThemeIndex];
if (theme === 'dark') return themeConfig.borderColorDark;
if (theme === 'light') return themeConfig.borderColorLight;
return getSystemTheme() === 'dark' ? themeConfig.borderColorDark : themeConfig.borderColorLight;
}
function updateChartColors() {
@@ -881,7 +343,7 @@
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -898,10 +360,13 @@
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
const textColor = getChartTextColor();
const borderColor = getChartBorderColor();
chart = new Chart(ctx, {
type: 'doughnut',
@@ -909,9 +374,9 @@
labels: chartData.map(g => g.name),
datasets: [{
data: chartData.map(g => g.playtime),
backgroundColor: colors.slice(0, chartData.length),
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 2
backgroundColor: themeConfig.colors.slice(0, chartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
@@ -922,29 +387,34 @@
labels: {
color: textColor,
font: {
family: "'Inter', sans-serif",
size: 11
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: 'circle'
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { weight: 'bold' },
bodyFont: { weight: 'normal' },
cornerRadius: 8,
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
titleFont: { weight: 'bold', family: themeConfig.fontFamily },
bodyFont: { weight: 'normal', family: themeConfig.fontFamily },
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
callbacks: {
title: function(context) {
return context[0].label;
return themeConfig.uppercaseTooltip ? context[0].label.toUpperCase() : context[0].label;
},
beforeBody: function(context) {
const index = context[0].dataIndex;
const service = chartData[index].service;
if (service && service !== 'others') {
return service.charAt(0).toUpperCase() + service.slice(1);
return themeConfig.uppercaseTooltip ? service.toUpperCase() : service.charAt(0).toUpperCase() + service.slice(1);
}
return '';
},
@@ -980,9 +450,9 @@
labels: categoriesChartData.map(c => c.name),
datasets: [{
data: categoriesChartData.map(c => c.playtime),
backgroundColor: colors.slice(0, categoriesChartData.length),
borderColor: 'rgba(255, 255, 255, 0.2)',
borderWidth: 2
backgroundColor: themeConfig.colors.slice(0, categoriesChartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
@@ -993,18 +463,25 @@
labels: {
color: textColor,
font: {
family: "'Inter', sans-serif",
size: 11
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: 'circle'
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
cornerRadius: 8,
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
titleFont: { family: themeConfig.fontFamily },
bodyFont: { family: themeConfig.fontFamily },
callbacks: {
label: function(context) {
const value = context.raw;
@@ -1039,7 +516,7 @@
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
<span class="color-box" style="background: ${themeConfig.colors[index]}"></span>
${game.name}${serviceBadge}${categoriesBadges}
</td>
<td class="time">${formatTime(game.playtime)}</td>
@@ -1093,7 +570,7 @@
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
<span class="color-box" style="background: ${themeConfig.colors[index]}"></span>
${cat.name} <span class="service-badge">${cat.gameCount} games</span>
</td>
<td class="time">${formatTime(cat.playtime)}</td>
@@ -1112,7 +589,7 @@
othersRow.innerHTML = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
<span class="color-box" style="background: ${themeConfig.colors[othersIndex]}"></span>
Others (${otherCategories.length} categories)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
@@ -1143,6 +620,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} else {
const topRunners = runnersData.slice(0, topN);
const otherRunners = runnersData.slice(topN);
topRunners.forEach((runner, index) => {
const percent = ((runner.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
runnersTbody.appendChild(row);
});
if (otherRunners.length > 0) {
const othersPlaytime = otherRunners.reduce((sum, r) => sum + r.playtime, 0);
const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1);
const othersIndex = topRunners.length;
const othersRow = document.createElement('tr');
othersRow.className = 'others-row';
othersRow.innerHTML = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${themeConfig.colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
runnersTbody.appendChild(othersRow);
const detailRows = [];
otherRunners.forEach((otherRunner, otherIndex) => {
const otherPercent = ((otherRunner.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);

File diff suppressed because it is too large Load Diff

View File

@@ -582,6 +582,7 @@
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -614,6 +615,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -728,12 +744,24 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
const runnerMap = {};
filtered.forEach(g => {
const runner = g.runner || 'unknown';
if (!runnerMap[runner]) {
runnerMap[runner] = { name: runner, playtime: 0, gameCount: 0 };
}
runnerMap[runner].playtime += g.playtime;
runnerMap[runner].gameCount++;
});
const runnersData = Object.values(runnerMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, totalPlaytime, totalGames };
}
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -750,6 +778,8 @@
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
@@ -988,6 +1018,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} else {
const topRunners = runnersData.slice(0, topN);
const otherRunners = runnersData.slice(topN);
topRunners.forEach((runner, index) => {
const percent = ((runner.playtime / totalPlaytime) * 100).toFixed(1);
const row = document.createElement('tr');
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
runnersTbody.appendChild(row);
});
if (otherRunners.length > 0) {
const othersPlaytime = otherRunners.reduce((sum, r) => sum + r.playtime, 0);
const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1);
const othersIndex = topRunners.length;
const othersRow = document.createElement('tr');
othersRow.className = 'others-row';
othersRow.innerHTML = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
runnersTbody.appendChild(othersRow);
const detailRows = [];
otherRunners.forEach((otherRunner, otherIndex) => {
const otherPercent = ((otherRunner.playtime / totalPlaytime) * 100).toFixed(1);
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);