Add Mac OS 9 Platinum style to report
- Restyle report with Mac OS 9.2.2 Platinum theme - Add Charcoal font for UI elements, Monaco for numeric data - Custom window chrome with titlebars and close buttons - Mac-style checkboxes, scrollbars, and tabs - Tabbed interface for game/category summaries using 9-patch tabs - Filter games by service with styled checkboxes - Support custom tiled background via --background flag - Add --assets flag for Platinum assets directory Platinum assets from grassmunk's Platinum9 GTK2 theme: https://github.com/grassmunk/Platinum9/ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,54 +29,117 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<title>Lutris Playtime Report</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--card-bg: rgba(33, 34, 44, 0.75);
|
||||
--card-hover: rgba(40, 42, 54, 0.85);
|
||||
--text-color: #eee;
|
||||
--text-muted: #aaa;
|
||||
--accent: #ff7f50;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--badge-bg: rgba(255, 255, 255, 0.15);
|
||||
--table-header: rgba(22, 33, 62, 0.8);
|
||||
@font-face {
|
||||
font-family: 'Charcoal';
|
||||
src: url('__FONT_CHARCOAL__') format('truetype');
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--card-bg: rgba(255, 255, 255, 0.75);
|
||||
--card-hover: rgba(245, 245, 245, 0.85);
|
||||
--text-color: #111;
|
||||
--text-muted: #555;
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
--badge-bg: rgba(0, 0, 0, 0.1);
|
||||
--table-header: rgba(240, 240, 240, 0.9);
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Monaco';
|
||||
src: url('__FONT_MONACO__') format('truetype');
|
||||
}
|
||||
:root {
|
||||
--mac-bg: #DDDDDD;
|
||||
--mac-window-bg: #DDDDDD;
|
||||
--mac-border-dark: #888888;
|
||||
--mac-border-light: #FFFFFF;
|
||||
--mac-text: #000000;
|
||||
--mac-text-muted: #555555;
|
||||
--mac-selection: #316AC5;
|
||||
--mac-selection-text: #FFFFFF;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 12px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: var(--mac-bg);
|
||||
background-image: url('__BACKGROUND_IMAGE__');
|
||||
background-repeat: repeat;
|
||||
min-height: 100vh;
|
||||
color: var(--text-color);
|
||||
color: var(--mac-text);
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
text-align: center;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
color: var(--mac-text);
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
h2 {
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--mac-text);
|
||||
}
|
||||
|
||||
/* Mac OS 9 Window Style */
|
||||
.window {
|
||||
background: var(--mac-window-bg);
|
||||
border-style: solid;
|
||||
border-color: #000000;
|
||||
border-width: 1px 2px 2px 1px;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 var(--mac-border-dark),
|
||||
inset 1px 1px 0 var(--mac-border-light);
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.window-titlebar {
|
||||
background: #BBBBBB;
|
||||
background-image: url('__TITLEBAR_BG__');
|
||||
background-repeat: repeat-x;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
border-bottom: 1px solid var(--mac-border-dark);
|
||||
position: relative;
|
||||
}
|
||||
.window-titlebar::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 25px;
|
||||
right: 25px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
background-image: url('__TITLE_STRIPES__');
|
||||
background-repeat: repeat-x;
|
||||
background-position: center;
|
||||
}
|
||||
.window-close {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background-image: url('__CLOSE_BTN__');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.window-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: var(--mac-text);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #BBBBBB;
|
||||
padding: 0 8px;
|
||||
margin: 0 auto;
|
||||
max-width: fit-content;
|
||||
}
|
||||
.window-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
/* Filters styled as Mac checkboxes */
|
||||
.filters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -86,226 +149,376 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
.filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--badge-bg);
|
||||
border-radius: 20px;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
user-select: none;
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
.filter-label:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
.filter-label input {
|
||||
accent-color: var(--accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.filter-label input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-image: url('__CHECK_OFF__');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.filter-label input[type="checkbox"]:checked {
|
||||
background-image: url('__CHECK_ON__');
|
||||
}
|
||||
.filter-label .service-name {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.filter-label .service-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
color: var(--mac-text-muted);
|
||||
}
|
||||
|
||||
/* Stats in embossed boxes */
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
gap: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 10px 20px;
|
||||
background: var(--mac-window-bg);
|
||||
border: 1px solid;
|
||||
border-color: var(--mac-border-dark) var(--mac-border-light) var(--mac-border-light) var(--mac-border-dark);
|
||||
box-shadow: inset 1px 1px 0 var(--mac-border-dark), inset -1px -1px 0 var(--mac-border-light);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
color: var(--mac-text);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 10px;
|
||||
color: var(--mac-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Chart container */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
max-width: 600px;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid;
|
||||
border-color: var(--mac-border-dark) var(--mac-border-light) var(--mac-border-light) var(--mac-border-dark);
|
||||
box-shadow: inset 1px 1px 0 var(--mac-border-dark);
|
||||
}
|
||||
|
||||
/* Tables styled like Mac OS 9 lists */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 11px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid var(--mac-border-dark);
|
||||
}
|
||||
th {
|
||||
background: var(--table-header);
|
||||
color: var(--accent);
|
||||
background: linear-gradient(to bottom, #EEEEEE 0%, #CCCCCC 100%);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--mac-border-dark);
|
||||
border-right: 1px solid var(--mac-border-light);
|
||||
font-weight: bold;
|
||||
color: var(--mac-text);
|
||||
}
|
||||
tr:hover {
|
||||
background: var(--card-hover);
|
||||
th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
td {
|
||||
padding: 3px 8px;
|
||||
border-bottom: 1px solid #CCCCCC;
|
||||
color: var(--mac-text);
|
||||
}
|
||||
tr:hover td {
|
||||
background: var(--mac-selection);
|
||||
color: var(--mac-selection-text);
|
||||
}
|
||||
tr:hover .service-badge {
|
||||
background: rgba(255,255,255,0.3);
|
||||
color: var(--mac-selection-text);
|
||||
}
|
||||
.time {
|
||||
font-family: monospace;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
.percent {
|
||||
font-family: monospace;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
.color-box {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--mac-border-dark);
|
||||
}
|
||||
.service-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--badge-bg);
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
background: #EEEEEE;
|
||||
border: 1px solid #CCCCCC;
|
||||
color: var(--mac-text-muted);
|
||||
margin-left: 6px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
padding: 20px;
|
||||
color: var(--mac-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Others row expansion */
|
||||
.others-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.others-row:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
.others-row td:first-child::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid var(--accent);
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
margin-right: 8px;
|
||||
transition: transform 0.2s;
|
||||
border-left: 5px solid var(--mac-text);
|
||||
border-top: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-right: 5px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.others-row:hover td:first-child::before {
|
||||
border-left-color: var(--mac-selection-text);
|
||||
}
|
||||
.others-row.expanded td:first-child::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.others-detail {
|
||||
display: none;
|
||||
background: var(--badge-bg);
|
||||
background: #F5F5F5;
|
||||
}
|
||||
.others-detail.visible {
|
||||
display: table-row;
|
||||
}
|
||||
.others-detail td {
|
||||
padding-left: 40px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-left: 25px;
|
||||
}
|
||||
.tables-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
|
||||
/* Mac OS 9 Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-left: 10px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.tables-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tables-container .card {
|
||||
height: auto !important;
|
||||
}
|
||||
.tab {
|
||||
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
|
||||
font-size: 11px;
|
||||
padding: 3px 15px 2px 15px;
|
||||
cursor: pointer;
|
||||
color: var(--mac-text);
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
margin-right: -1px;
|
||||
border-style: solid;
|
||||
border-width: 10px 11px 2px 11px;
|
||||
border-image: url('__TAB_INACTIVE__') 10 11 2 11 fill stretch;
|
||||
}
|
||||
@media (min-width: 901px) {
|
||||
.tables-container {
|
||||
align-items: stretch;
|
||||
}
|
||||
.tables-container .card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tables-container .card .table-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tab:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.table-section h2 {
|
||||
color: var(--accent);
|
||||
font-size: 1.2em;
|
||||
margin: 0 0 15px 0;
|
||||
.tab.active {
|
||||
border-image: url('__TAB_ACTIVE__') 10 11 2 11 fill stretch;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
margin-bottom: -1px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
.tab-content {
|
||||
background: #f3f3f3;
|
||||
border: 1px solid #000000;
|
||||
border-top: 1px solid #888888;
|
||||
padding: 10px;
|
||||
box-shadow: inset 1px 1px 0 #FFFFFF, inset -1px -1px 0 #888888;
|
||||
}
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
.tab-panel.active {
|
||||
display: block;
|
||||
}
|
||||
.table-wrapper {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
/* Mac OS 9 Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-image: url('__SCROLLBAR_TROUGH_V__');
|
||||
background-repeat: repeat-y;
|
||||
background-color: #CCCCCC;
|
||||
border-left: 1px solid #888888;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-image: url('__SCROLLBAR_THUMB_V__');
|
||||
background-repeat: repeat-y;
|
||||
background-size: contain;
|
||||
border: 1px solid;
|
||||
border-color: #FFFFFF #888888 #888888 #FFFFFF;
|
||||
background-color: #DDDDDD;
|
||||
min-height: 20px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background-color: #CCCCCC;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background-image: url('__SCROLLBAR_UP__');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-color: #DDDDDD;
|
||||
border: 1px solid;
|
||||
border-color: #FFFFFF #888888 #888888 #FFFFFF;
|
||||
height: 16px;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background-image: url('__SCROLLBAR_DOWN__');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-color: #DDDDDD;
|
||||
border: 1px solid;
|
||||
border-color: #FFFFFF #888888 #888888 #FFFFFF;
|
||||
height: 16px;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement:hover,
|
||||
::-webkit-scrollbar-button:vertical:end:increment:hover {
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement:active,
|
||||
::-webkit-scrollbar-button:vertical:end:increment:active {
|
||||
background-color: #BBBBBB;
|
||||
border-color: #888888 #FFFFFF #FFFFFF #888888;
|
||||
}
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: #DDDDDD;
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #DDDDDD #CCCCCC;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Lutris Playtime Report</h1>
|
||||
<div class="window">
|
||||
<div class="window-titlebar">
|
||||
<div class="window-close"></div>
|
||||
<div class="window-title">Lutris Playtime Report</div>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<div class="filters" id="filters"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="filters" id="filters"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="total-library">__TOTAL_LIBRARY__</div>
|
||||
<div class="stat-label">Games in Library</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="total-games">0</div>
|
||||
<div class="stat-label">Games Played</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="total-time">0h</div>
|
||||
<div class="stat-label">Total Playtime</div>
|
||||
<div class="window">
|
||||
<div class="window-titlebar">
|
||||
<div class="window-close"></div>
|
||||
<div class="window-title">Statistics</div>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="total-library">__TOTAL_LIBRARY__</div>
|
||||
<div class="stat-label">Games in Library</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="total-games">0</div>
|
||||
<div class="stat-label">Games Played</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-value" id="total-time">0h</div>
|
||||
<div class="stat-label">Total Playtime</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="chart-container">
|
||||
<canvas id="playtime-chart"></canvas>
|
||||
<div class="window">
|
||||
<div class="window-titlebar">
|
||||
<div class="window-close"></div>
|
||||
<div class="window-title">Playtime Distribution</div>
|
||||
</div>
|
||||
<div class="window-content">
|
||||
<div class="chart-container">
|
||||
<canvas id="playtime-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tables-container">
|
||||
<div class="card">
|
||||
<h2 class="table-section">Top Games</h2>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Game</th>
|
||||
<th>Playtime</th>
|
||||
<th style="text-align: right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="games-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="window">
|
||||
<div class="window-titlebar">
|
||||
<div class="window-close"></div>
|
||||
<div class="window-title">Summaries</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="table-section">By Category</h2>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Category</th>
|
||||
<th>Playtime</th>
|
||||
<th style="text-align: right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="categories-table"></tbody>
|
||||
</table>
|
||||
<div class="window-content">
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="games">Top Games</div>
|
||||
<div class="tab" data-tab="categories">By Category</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-panel active" id="tab-games">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Game</th>
|
||||
<th>Playtime</th>
|
||||
<th style="text-align: right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="games-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-panel" id="tab-categories">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Category</th>
|
||||
<th>Playtime</th>
|
||||
<th style="text-align: right">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="categories-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,14 +527,13 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
const allGames = __ALL_GAMES__;
|
||||
const topN = __TOP_N__;
|
||||
|
||||
// Colors for the chart
|
||||
// Mac OS 9 inspired colors
|
||||
const colors = [
|
||||
'#ff6384', '#36a2eb', '#ffce56', '#4bc0c0', '#9966ff',
|
||||
'#ff9f40', '#ff6384', '#c9cbcf', '#7bc043', '#ee4035',
|
||||
'#808080'
|
||||
'#336699', '#993366', '#669933', '#CC6633', '#663399',
|
||||
'#339966', '#996633', '#336666', '#993333', '#666699',
|
||||
'#888888'
|
||||
];
|
||||
|
||||
// Format hours to "Xh Ym" format
|
||||
function formatTime(hours) {
|
||||
const h = Math.floor(hours);
|
||||
const m = Math.round((hours - h) * 60);
|
||||
@@ -330,7 +542,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
return h + 'h ' + m + 'm';
|
||||
}
|
||||
|
||||
// Get unique services and their counts
|
||||
function getServices() {
|
||||
const services = {};
|
||||
allGames.forEach(g => {
|
||||
@@ -344,7 +555,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
.map(([name, data]) => ({ name, ...data }));
|
||||
}
|
||||
|
||||
// Build filter checkboxes
|
||||
const services = getServices();
|
||||
const filtersDiv = document.getElementById('filters');
|
||||
services.forEach(service => {
|
||||
@@ -358,11 +568,9 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
filtersDiv.appendChild(label);
|
||||
});
|
||||
|
||||
// Chart instance
|
||||
let chart = null;
|
||||
const ctx = document.getElementById('playtime-chart').getContext('2d');
|
||||
|
||||
// Get selected services
|
||||
function getSelectedServices() {
|
||||
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
|
||||
return Array.from(checkboxes)
|
||||
@@ -370,7 +578,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
.map(cb => cb.value);
|
||||
}
|
||||
|
||||
// Filter and aggregate data
|
||||
function getFilteredData(selectedServices) {
|
||||
const filtered = allGames
|
||||
.filter(g => selectedServices.includes(g.service))
|
||||
@@ -383,17 +590,14 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
const totalPlaytime = filtered.reduce((sum, g) => sum + g.playtime, 0);
|
||||
const totalGames = filtered.length;
|
||||
|
||||
// Get top N games
|
||||
const topGames = filtered.slice(0, topN).map(g => ({
|
||||
name: g.name,
|
||||
playtime: g.playtime,
|
||||
service: g.service
|
||||
}));
|
||||
|
||||
// Games in "Others" category
|
||||
let othersGames = [];
|
||||
|
||||
// Add "Others" if needed
|
||||
if (filtered.length > topN) {
|
||||
othersGames = filtered.slice(topN).map(g => ({
|
||||
name: g.name,
|
||||
@@ -409,7 +613,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregate by category
|
||||
const categoryMap = {};
|
||||
filtered.forEach(g => {
|
||||
if (g.categories && g.categories.length > 0) {
|
||||
@@ -429,16 +632,13 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
|
||||
}
|
||||
|
||||
// Update the display
|
||||
function updateDisplay() {
|
||||
const selectedServices = getSelectedServices();
|
||||
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
|
||||
|
||||
// Update stats
|
||||
document.getElementById('total-games').textContent = totalGames;
|
||||
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
|
||||
|
||||
// Update chart
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
@@ -458,8 +658,8 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
datasets: [{
|
||||
data: chartData.map(g => g.playtime),
|
||||
backgroundColor: colors.slice(0, chartData.length),
|
||||
borderColor: '#1a1a2e',
|
||||
borderWidth: 2
|
||||
borderColor: '#FFFFFF',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -468,8 +668,12 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#eee',
|
||||
padding: 15
|
||||
color: '#000000',
|
||||
font: {
|
||||
family: "'Charcoal', 'Chicago', Geneva, sans-serif",
|
||||
size: 10
|
||||
},
|
||||
padding: 10
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
@@ -485,7 +689,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
}
|
||||
});
|
||||
|
||||
// Update table
|
||||
const tbody = document.getElementById('games-table');
|
||||
tbody.innerHTML = '';
|
||||
chartData.forEach((game, index) => {
|
||||
@@ -509,7 +712,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
// Add expandable rows for "Others"
|
||||
if (isOthers && othersGames.length > 0) {
|
||||
const detailRows = [];
|
||||
othersGames.forEach((otherGame, otherIndex) => {
|
||||
@@ -529,7 +731,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
detailRows.push(detailRow);
|
||||
});
|
||||
|
||||
// Toggle expand/collapse on click
|
||||
row.addEventListener('click', () => {
|
||||
row.classList.toggle('expanded');
|
||||
detailRows.forEach(dr => dr.classList.toggle('visible'));
|
||||
@@ -537,7 +738,6 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
}
|
||||
});
|
||||
|
||||
// Update categories table
|
||||
const catTbody = document.getElementById('categories-table');
|
||||
catTbody.innerHTML = '';
|
||||
if (categoriesData.length === 0) {
|
||||
@@ -557,27 +757,42 @@ HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for filter changes
|
||||
filtersDiv.addEventListener('change', updateDisplay);
|
||||
|
||||
// Initial render
|
||||
updateDisplay();
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const tabId = tab.dataset.tab;
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + tabId).classList.add('active');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def load_asset_as_base64(path: Path, mime_type: str) -> str:
|
||||
"""Load a file and return it as a base64 data URL."""
|
||||
if path.exists():
|
||||
with open(path, "rb") as f:
|
||||
data = base64.b64encode(f.read()).decode("utf-8")
|
||||
return f"data:{mime_type};base64,{data}"
|
||||
return ""
|
||||
|
||||
|
||||
def get_all_games(db_path: str) -> tuple[list[dict], int]:
|
||||
"""Query the database and return all games with playtime and categories, plus total library count."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get total games in library
|
||||
cursor.execute("SELECT COUNT(*) FROM games")
|
||||
total_library = cursor.fetchone()[0]
|
||||
|
||||
# Get games with playtime > 0
|
||||
cursor.execute("""
|
||||
SELECT id, name, playtime, COALESCE(service, 'local') as service
|
||||
FROM games
|
||||
@@ -586,7 +801,6 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
|
||||
""")
|
||||
games_rows = cursor.fetchall()
|
||||
|
||||
# Get categories for each game
|
||||
cursor.execute("""
|
||||
SELECT gc.game_id, c.name
|
||||
FROM games_categories gc
|
||||
@@ -595,7 +809,6 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
|
||||
categories_rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Build game_id -> categories mapping
|
||||
game_categories = {}
|
||||
for game_id, category in categories_rows:
|
||||
if game_id not in game_categories:
|
||||
@@ -614,7 +827,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, bg_image_path: str = None) -> None:
|
||||
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, bg_image_path: str = None) -> None:
|
||||
"""Generate the HTML report."""
|
||||
all_games, total_library = get_all_games(db_path)
|
||||
|
||||
@@ -625,17 +838,52 @@ def generate_report(db_path: str, output_path: str, top_n: int, bg_image_path: s
|
||||
total_playtime = sum(g["playtime"] for g in all_games)
|
||||
total_games = len(all_games)
|
||||
|
||||
# Load background image as base64
|
||||
bg_data_url = ""
|
||||
assets_path = Path(assets_dir)
|
||||
|
||||
# Load background image (custom or default stripes)
|
||||
if bg_image_path and Path(bg_image_path).exists():
|
||||
with open(bg_image_path, "rb") as f:
|
||||
bg_base64 = base64.b64encode(f.read()).decode("utf-8")
|
||||
bg_data_url = f"data:image/png;base64,{bg_base64}"
|
||||
background_image = load_asset_as_base64(Path(bg_image_path), "image/png")
|
||||
else:
|
||||
background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png")
|
||||
|
||||
# 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 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")
|
||||
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")
|
||||
|
||||
# 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 = HTML_TEMPLATE.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__", bg_data_url)
|
||||
html = html.replace("__FONT_CHARCOAL__", font_charcoal)
|
||||
html = html.replace("__FONT_MONACO__", font_monaco)
|
||||
html = html.replace("__BACKGROUND_IMAGE__", background_image)
|
||||
html = html.replace("__TITLEBAR_BG__", titlebar_bg)
|
||||
html = html.replace("__TITLE_STRIPES__", title_stripes)
|
||||
html = html.replace("__CLOSE_BTN__", close_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}")
|
||||
@@ -664,10 +912,15 @@ def main():
|
||||
default=10,
|
||||
help="Number of top games to show individually (default: 10)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assets",
|
||||
default="Platinum",
|
||||
help="Path to Platinum assets directory (default: Platinum)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--background",
|
||||
default="background.png",
|
||||
help="Path to background image (default: background.png)"
|
||||
default=None,
|
||||
help="Path to background image for tiling (default: Platinum stripes pattern)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -676,7 +929,11 @@ def main():
|
||||
print(f"Error: Database file not found: {args.db}")
|
||||
return 1
|
||||
|
||||
generate_report(args.db, args.output, args.top, args.background)
|
||||
if not Path(args.assets).exists():
|
||||
print(f"Error: Assets directory not found: {args.assets}")
|
||||
return 1
|
||||
|
||||
generate_report(args.db, args.output, args.top, args.assets, args.background)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user