First commit
This commit is contained in:
commit
2fbafed46e
8 changed files with 643 additions and 0 deletions
76
generate_stats.py
Normal file
76
generate_stats.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
#!/usr/bin/env -S uv run
|
||||||
|
# Needs "uv" to be run. chmod +x and then ./generate_stats.py
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
# dependencies = [
|
||||||
|
# "jinja2", "httpx"
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
import httpx
|
||||||
|
from typing import Dict, List, TypedDict
|
||||||
|
|
||||||
|
class ReleaseStats(TypedDict):
|
||||||
|
releases: List[Dict]
|
||||||
|
|
||||||
|
async def fetch_github_stats(repo: str) -> ReleaseStats:
|
||||||
|
"""Fetch GitHub release statistics"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Fetch releases with download counts
|
||||||
|
releases_resp = await client.get(
|
||||||
|
f"https://api.github.com/repos/{repo}/releases"
|
||||||
|
)
|
||||||
|
releases = releases_resp.json()
|
||||||
|
|
||||||
|
# Process releases to include download counts per asset
|
||||||
|
processed_releases = []
|
||||||
|
for release in releases:
|
||||||
|
processed_releases.append({
|
||||||
|
"name": release["name"] or release["tag_name"],
|
||||||
|
"published_at": release["published_at"],
|
||||||
|
"html_url": release["html_url"],
|
||||||
|
"assets": [{
|
||||||
|
"name": asset["name"],
|
||||||
|
"download_count": asset["download_count"],
|
||||||
|
"download_url": asset["browser_download_url"]
|
||||||
|
} for asset in release["assets"]]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"releases": processed_releases
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_site(stats: ReleaseStats) -> None:
|
||||||
|
"""Generate the static site using the templates"""
|
||||||
|
output_dir = Path("output")
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
templates_dir = Path("templates")
|
||||||
|
templates_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
env = Environment(loader=FileSystemLoader("templates"))
|
||||||
|
|
||||||
|
template = env.get_template("index.html")
|
||||||
|
index_content = template.render(
|
||||||
|
stats=stats,
|
||||||
|
generated_at=datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(output_dir / "index.html", "w") as f:
|
||||||
|
f.write(index_content)
|
||||||
|
|
||||||
|
with open(output_dir / "stats.json", "w") as f:
|
||||||
|
json.dump(stats, f)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
repo = os.getenv("GITHUB_REPO", "freedomofpress/dangerzone")
|
||||||
|
stats = await fetch_github_stats(repo)
|
||||||
|
generate_site(stats)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
asyncio.run(main())
|
8
output/app.js
Normal file
8
output/app.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { h, render } from 'https://esm.sh/preact';
|
||||||
|
import { useState } from 'https://esm.sh/preact/hooks';
|
||||||
|
import { App } from './components/App.js';
|
||||||
|
|
||||||
|
const stats = window.INITIAL_STATS;
|
||||||
|
const generatedAt = window.GENERATED_AT;
|
||||||
|
|
||||||
|
render(h(App, { stats, generatedAt }), document.getElementById('app'));
|
28
output/components/App.js
Normal file
28
output/components/App.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { h } from 'https://esm.sh/preact';
|
||||||
|
import { useState } from 'https://esm.sh/preact/hooks';
|
||||||
|
import { Stats } from './Stats.js';
|
||||||
|
import { Chart } from './Chart.js';
|
||||||
|
|
||||||
|
export function App({ stats, generatedAt }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('charts');
|
||||||
|
|
||||||
|
return h('div', { class: 'container' }, [
|
||||||
|
h('header', { class: 'header' }, [
|
||||||
|
h('h1', null, 'Dangerzone Release Stats'),
|
||||||
|
h('p', null, `Generated at: ${new Date(generatedAt).toLocaleString()}`)
|
||||||
|
]),
|
||||||
|
h('nav', { class: 'tabs' }, [
|
||||||
|
h('button', {
|
||||||
|
class: activeTab === 'charts' ? 'active' : '',
|
||||||
|
onClick: () => setActiveTab('charts')
|
||||||
|
}, 'Charts'),
|
||||||
|
h('button', {
|
||||||
|
class: activeTab === 'overview' ? 'active' : '',
|
||||||
|
onClick: () => setActiveTab('overview')
|
||||||
|
}, 'Overview')
|
||||||
|
]),
|
||||||
|
activeTab === 'charts'
|
||||||
|
? h(Chart, { stats })
|
||||||
|
: h(Stats, { stats })
|
||||||
|
]);
|
||||||
|
}
|
113
output/components/Chart.js
vendored
Normal file
113
output/components/Chart.js
vendored
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { h } from 'https://esm.sh/preact';
|
||||||
|
|
||||||
|
function getPlatform(assetName) {
|
||||||
|
assetName = assetName.toLowerCase();
|
||||||
|
if (assetName.includes('msi')) return 'Windows';
|
||||||
|
if (assetName.includes('arm64.dmg')) return 'Mac Silicon';
|
||||||
|
if (assetName.includes('i686.dmg') || assetName.includes('.dmg')) return 'Mac Intel';
|
||||||
|
if (assetName.includes('container')) return 'Container';
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorForPlatform(platform) {
|
||||||
|
switch (platform) {
|
||||||
|
case 'Windows': return '#00A4EF';
|
||||||
|
case 'Mac Intel': return '#A2AAAD';
|
||||||
|
case 'Mac Silicon': return '#C4C4C4';
|
||||||
|
case 'Container': return '#FFD700';
|
||||||
|
default: return '#FF69B4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chart({ stats }) {
|
||||||
|
console.log('Received stats:', stats);
|
||||||
|
|
||||||
|
if (!stats || !stats.releases || !Array.isArray(stats.releases)) {
|
||||||
|
return h('div', { class: 'charts' }, 'No data available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const platforms = ['Windows', 'Mac Intel', 'Mac Silicon', 'Container', 'Other'];
|
||||||
|
|
||||||
|
const downloadsByRelease = stats.releases
|
||||||
|
.filter(release => release && release.name && release.published_at)
|
||||||
|
.map(release => {
|
||||||
|
console.log('Processing release:', release);
|
||||||
|
return {
|
||||||
|
name: (release.name || '').replace(/^Dangerzone /, ''),
|
||||||
|
date: release.published_at,
|
||||||
|
platforms: platforms.reduce((acc, platform) => {
|
||||||
|
acc[platform] = 0;
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
total: 0,
|
||||||
|
assets: release.assets || []
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.map(release => {
|
||||||
|
release.assets.forEach(asset => {
|
||||||
|
const platform = getPlatform(asset.name);
|
||||||
|
release.platforms[platform] += asset.download_count || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
release.total = Object.values(release.platforms).reduce((a, b) => a + b, 0);
|
||||||
|
return release;
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
|
||||||
|
console.log('Processed releases:', downloadsByRelease);
|
||||||
|
|
||||||
|
if (downloadsByRelease.length === 0) {
|
||||||
|
return h('div', { class: 'charts' }, 'No release data available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDownloads = Math.max(...downloadsByRelease.map(r => r.total));
|
||||||
|
|
||||||
|
return h('div', { class: 'charts' }, [
|
||||||
|
h('div', { class: 'chart horizontal' }, [
|
||||||
|
h('div', { class: 'chart-legend' },
|
||||||
|
platforms.map(platform =>
|
||||||
|
h('div', { class: 'legend-item' }, [
|
||||||
|
h('span', {
|
||||||
|
class: 'legend-color',
|
||||||
|
style: `background-color: ${getColorForPlatform(platform)}`
|
||||||
|
}),
|
||||||
|
h('span', { class: 'legend-label' }, platform)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
),
|
||||||
|
h('div', { class: 'chart-container horizontal' },
|
||||||
|
downloadsByRelease.map(release =>
|
||||||
|
h('div', { class: 'bar-group horizontal' }, [
|
||||||
|
h('div', { class: 'bar-label horizontal' }, [
|
||||||
|
h('span', { class: 'version-label' }, release.name),
|
||||||
|
h('span', { class: 'date-label' }, formatDate(release.date))
|
||||||
|
]),
|
||||||
|
h('div', { class: 'stacked-bars horizontal' },
|
||||||
|
platforms.map(platform => {
|
||||||
|
const width = (release.platforms[platform] / maxDownloads) * 400;
|
||||||
|
return h('div', {
|
||||||
|
class: 'bar stacked-bar horizontal',
|
||||||
|
style: `
|
||||||
|
width: ${width}px;
|
||||||
|
background-color: ${getColorForPlatform(platform)};
|
||||||
|
`,
|
||||||
|
title: `${platform}: ${release.platforms[platform].toLocaleString()} downloads`
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
h('div', { class: 'total-downloads' },
|
||||||
|
`${release.total.toLocaleString()} downloads`
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
30
output/components/Stats.js
Normal file
30
output/components/Stats.js
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { h } from 'https://esm.sh/preact';
|
||||||
|
|
||||||
|
export function Stats({ stats }) {
|
||||||
|
return h('div', { class: 'stats-grid' }, [
|
||||||
|
...stats.releases.map(release =>
|
||||||
|
h('div', { class: 'stat-card' }, [
|
||||||
|
h('h2', null, [
|
||||||
|
h('a', { href: release.html_url, target: '_blank' }, release.name)
|
||||||
|
]),
|
||||||
|
h('p', { class: 'release-date' },
|
||||||
|
new Date(release.published_at).toLocaleDateString()
|
||||||
|
),
|
||||||
|
h('div', { class: 'assets-list' },
|
||||||
|
release.assets.map(asset =>
|
||||||
|
h('div', { class: 'asset-item' }, [
|
||||||
|
h('a', {
|
||||||
|
href: asset.download_url,
|
||||||
|
class: 'asset-name',
|
||||||
|
target: '_blank'
|
||||||
|
}, asset.name),
|
||||||
|
h('span', { class: 'download-count' },
|
||||||
|
`${asset.download_count.toLocaleString()} downloads`
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
1
output/stats.json
Normal file
1
output/stats.json
Normal file
File diff suppressed because one or more lines are too long
367
output/styles.css
Normal file
367
output/styles.css
Normal file
|
@ -0,0 +1,367 @@
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #24292e;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
border-bottom-color: #0366d6;
|
||||||
|
color: #0366d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card a {
|
||||||
|
color: #0366d6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.open {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.closed {
|
||||||
|
background: #d73a49;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 250px;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container.stacked {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 2rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-group.stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
width: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 30px;
|
||||||
|
background: #0366d6;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.stacked-bar {
|
||||||
|
width: 100%;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.stacked-bar:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
transform-origin: top center;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bar {
|
||||||
|
background: #0366d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.issue-bar {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-date {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assets-list {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name {
|
||||||
|
color: #0366d6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
background: #f1f8ff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-bar {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-total {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart.horizontal {
|
||||||
|
margin: 2rem 0;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container.horizontal {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
height: auto;
|
||||||
|
min-height: 400px;
|
||||||
|
padding: calc(1rem + 70px) 2rem 1rem 12rem;
|
||||||
|
gap: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-group.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
position: relative;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-group.horizontal:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label.horizontal {
|
||||||
|
position: absolute;
|
||||||
|
left: -11rem;
|
||||||
|
width: 10rem;
|
||||||
|
transform: none;
|
||||||
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 1rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-bars.horizontal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 400px;
|
||||||
|
height: 30px;
|
||||||
|
position: relative;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.stacked-bar.horizontal {
|
||||||
|
height: 30px;
|
||||||
|
display: inline-block;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar.stacked-bar.horizontal+.bar.stacked-bar.horizontal {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-total.horizontal {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 10px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-downloads {
|
||||||
|
margin-left: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #94a2b0;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 100px;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
20
templates/index.html
Normal file
20
templates/index.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dangerzone Release Stats</title>
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script>
|
||||||
|
window.INITIAL_STATS = {{ stats | tojson }};
|
||||||
|
window.GENERATED_AT = "{{ generated_at }}";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
Reference in a new issue