Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf444e8dd4 | |||
| a74a89c747 | |||
| 1695f7393e | |||
| 4468e68110 | |||
| da08eca4ab |
@ -1,68 +0,0 @@
|
|||||||
name: build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["**"]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
|
||||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
|
|
||||||
- name: Cache pip/poetry
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.cache/pip
|
|
||||||
~/.cache/pypoetry
|
|
||||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-py311-
|
|
||||||
|
|
||||||
- name: Install Poetry
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install poetry
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: |
|
|
||||||
cp vrobbler.conf.test vrobbler.conf
|
|
||||||
poetry install --with test
|
|
||||||
|
|
||||||
- name: Pytest with coverage
|
|
||||||
run: |
|
|
||||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
|
||||||
|
|
||||||
- name: Notify success (ntfy)
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
curl -fsS \
|
|
||||||
-H "Title: vrobbler CI success" \
|
|
||||||
-H "Priority: low" \
|
|
||||||
-H "Tags: success,vrobbler" \
|
|
||||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
|
||||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
|
||||||
https://ntfy.unbl.ink/drone
|
|
||||||
|
|
||||||
- name: Notify failure (ntfy)
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
curl -fsS \
|
|
||||||
-H "Title: vrobbler CI failure" \
|
|
||||||
-H "Priority: high" \
|
|
||||||
-H "Tags: failure,vrobbler" \
|
|
||||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
|
||||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
|
||||||
https://ntfy.unbl.ink/drone
|
|
||||||
@ -1,8 +1,14 @@
|
|||||||
name: deploy
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: ["**"]
|
||||||
tags: ["*"]
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@ -68,6 +74,7 @@ jobs:
|
|||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: [test]
|
needs: [test]
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
16
PROJECT.org
16
PROJECT.org
@ -605,6 +605,22 @@ independent of the email flow it was originally creatdd for
|
|||||||
|
|
||||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||||
|
|
||||||
|
* Version 58.8 [1/1]
|
||||||
|
** DONE [#B] Clean up trend templates :trends:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.7 [2/2]
|
||||||
|
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 58.6 [1/1]
|
* Version 58.6 [1/1]
|
||||||
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
|
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "58.6"
|
version = "58.8"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from charts.views import (
|
|||||||
BirdsChartView,
|
BirdsChartView,
|
||||||
ChartDetailView,
|
ChartDetailView,
|
||||||
ChartRecordView,
|
ChartRecordView,
|
||||||
|
MalojaChartsView,
|
||||||
SpotifyTracksView,
|
SpotifyTracksView,
|
||||||
)
|
)
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
@ -11,6 +12,7 @@ app_name = "charts"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||||
|
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
|
||||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||||
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
||||||
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||||
|
|||||||
@ -159,80 +159,6 @@ class ChartRecordView(TemplateView):
|
|||||||
context["week"] = current_week
|
context["week"] = current_week
|
||||||
context["day"] = current_day
|
context["day"] = current_day
|
||||||
|
|
||||||
context["chart_keys"] = {
|
|
||||||
"today": "Today",
|
|
||||||
"week": "This Week",
|
|
||||||
"month": "This Month",
|
|
||||||
"year": "This Year",
|
|
||||||
"all": "All Time",
|
|
||||||
}
|
|
||||||
|
|
||||||
context["maloja_charts"] = {
|
|
||||||
"artist": {
|
|
||||||
"today": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "artist", year=year, month=month, day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"week": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "artist", year=year, week=week,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"month": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "artist", year=year, month=month,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"year": list(
|
|
||||||
self.get_charts_for_period(user, "artist", year=year)
|
|
||||||
),
|
|
||||||
"all": list(self.get_charts_for_period(user, "artist")),
|
|
||||||
},
|
|
||||||
"album": {
|
|
||||||
"today": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "album", year=year, month=month, day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"week": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "album", year=year, week=week,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"month": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "album", year=year, month=month,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"year": list(
|
|
||||||
self.get_charts_for_period(user, "album", year=year)
|
|
||||||
),
|
|
||||||
"all": list(self.get_charts_for_period(user, "album")),
|
|
||||||
},
|
|
||||||
"tv_series": {
|
|
||||||
"today": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "tv_series", year=year, month=month, day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"week": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "tv_series", year=year, week=week,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"month": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "tv_series", year=year, month=month,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"year": list(
|
|
||||||
self.get_charts_for_period(user, "tv_series", year=year)
|
|
||||||
),
|
|
||||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# List-group tables default to week-level when no date param (matches active tab)
|
# List-group tables default to week-level when no date param (matches active tab)
|
||||||
if not date_param:
|
if not date_param:
|
||||||
list_year = current_year
|
list_year = current_year
|
||||||
@ -510,6 +436,57 @@ class ChartRecordView(TemplateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MalojaChartsView(ChartRecordView):
|
||||||
|
"""Three maloja-themed image grid widgets (artists, albums, TV series)
|
||||||
|
with Today/Week/Month/Year/All tabs. Each tab computes its own period
|
||||||
|
from the current date — no query param needed."""
|
||||||
|
|
||||||
|
template_name = "charts/maloja_charts.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(ChartRecordView, self).get_context_data(**kwargs)
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
context["maloja_charts"] = {}
|
||||||
|
context["chart_keys"] = {}
|
||||||
|
return context
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
now = now_user_timezone(user.profile)
|
||||||
|
today = now.date()
|
||||||
|
|
||||||
|
context["chart_keys"] = {
|
||||||
|
"today": "Today",
|
||||||
|
"week": "This Week",
|
||||||
|
"month": "This Month",
|
||||||
|
"year": "This Year",
|
||||||
|
"all": "All Time",
|
||||||
|
}
|
||||||
|
|
||||||
|
tab_params = {
|
||||||
|
"today": {"year": today.year, "month": today.month, "day": today.day},
|
||||||
|
"week": {"year": today.year, "week": today.isocalendar()[1]},
|
||||||
|
"month": {"year": today.year, "month": today.month},
|
||||||
|
"year": {"year": today.year},
|
||||||
|
}
|
||||||
|
|
||||||
|
maloja_charts = {}
|
||||||
|
for media_type in ("artist", "album", "tv_series"):
|
||||||
|
tabs = {}
|
||||||
|
for key in ("today", "week", "month", "year"):
|
||||||
|
tabs[key] = list(
|
||||||
|
self.get_charts_for_period(user, media_type, **tab_params[key])
|
||||||
|
)
|
||||||
|
tabs["all"] = list(
|
||||||
|
self.get_charts_for_period(user, media_type)
|
||||||
|
)
|
||||||
|
maloja_charts[media_type] = tabs
|
||||||
|
|
||||||
|
context["maloja_charts"] = maloja_charts
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
MEDIA_TYPE_LABELS = {
|
MEDIA_TYPE_LABELS = {
|
||||||
"artist": ("🎤", "Top Artists"),
|
"artist": ("🎤", "Top Artists"),
|
||||||
"album": ("💿", "Top Albums"),
|
"album": ("💿", "Top Albums"),
|
||||||
|
|||||||
@ -1,45 +1,78 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.distribution %}
|
{% if data.distribution %}
|
||||||
|
{{ data.distribution|json_script:"activity-distribution-data" }}
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div class="table-responsive">
|
<canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
|
||||||
<table class="table table-striped table-sm">
|
<script>
|
||||||
<thead>
|
(function() {
|
||||||
<tr>
|
var el = document.getElementById('activity-distribution-data');
|
||||||
<th>Media Type</th>
|
if (!el) return;
|
||||||
<th class="text-end">Total</th>
|
var data = JSON.parse(el.textContent);
|
||||||
<th class="text-end">Completed</th>
|
if (!data.length) return;
|
||||||
<th class="text-end">%</th>
|
var canvas = document.getElementById('activityDistChart');
|
||||||
<th>Distribution</th>
|
var ctx = canvas.getContext('2d');
|
||||||
</tr>
|
var W = canvas.width;
|
||||||
</thead>
|
var rowH = 34;
|
||||||
<tbody>
|
var labelW = 140;
|
||||||
{% with max=data.distribution.0.count %}
|
var barLeft = labelW + 8;
|
||||||
{% for entry in data.distribution %}
|
var barRight = W - 80;
|
||||||
<tr>
|
var barMaxW = barRight - barLeft;
|
||||||
<td>{{ entry.media_type }}</td>
|
var padTop = 8;
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
|
||||||
<td class="text-end">{{ entry.completed }}</td>
|
var maxCount = data[0].count;
|
||||||
<td class="text-end">{{ entry.pct }}%</td>
|
|
||||||
<td style="width: 30%;">
|
// Set canvas height based on data
|
||||||
{% if max > 0 %}
|
canvas.height = data.length * rowH + padTop;
|
||||||
<div class="progress" style="height: 12px;">
|
|
||||||
<div class="progress-bar" role="progressbar"
|
function roundRect(x, y, w, h, r) {
|
||||||
style="width: {{ entry.pct }}%;"
|
ctx.beginPath();
|
||||||
aria-valuenow="{{ entry.count }}"
|
ctx.moveTo(x + r, y);
|
||||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
ctx.lineTo(x + w - r, y);
|
||||||
</div>
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
</div>
|
ctx.lineTo(x + w, y + h - r);
|
||||||
{% endif %}
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
</td>
|
ctx.lineTo(x + r, y + h);
|
||||||
</tr>
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
{% endfor %}
|
ctx.lineTo(x, y + r);
|
||||||
{% endwith %}
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
</tbody>
|
ctx.closePath();
|
||||||
</table>
|
}
|
||||||
</div>
|
|
||||||
|
data.forEach(function(entry, i) {
|
||||||
|
var y = padTop + i * rowH;
|
||||||
|
var pct = entry.count / maxCount;
|
||||||
|
var barW = pct * barMaxW;
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(entry.media_type, labelW - 6, y + rowH / 2);
|
||||||
|
|
||||||
|
// Bar background
|
||||||
|
ctx.fillStyle = '#f3f4f6';
|
||||||
|
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Bar fill — green (top) to red (bottom)
|
||||||
|
var hue = 120 * (1 - i / (data.length - 1 || 1));
|
||||||
|
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
|
||||||
|
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Count + percentage label on the right
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = 'bold 13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(entry.count + ' (' + entry.pct + '%)', barRight + 6, y + rowH / 2);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No activity data found.</p>
|
<p class="text-muted">No activity data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,43 +1,74 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.moods %}
|
{% if data.moods %}
|
||||||
|
{{ data.moods|json_script:"mood-distribution-data" }}
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
||||||
· Positive: <strong>{{ data.positive_count }}</strong>
|
· Positive: <strong>{{ data.positive_count }}</strong>
|
||||||
· Negative: <strong>{{ data.negative_count }}</strong>
|
· Negative: <strong>{{ data.negative_count }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div class="table-responsive">
|
<canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
|
||||||
<table class="table table-striped table-sm">
|
<script>
|
||||||
<thead>
|
(function() {
|
||||||
<tr>
|
var el = document.getElementById('mood-distribution-data');
|
||||||
<th>Mood</th>
|
if (!el) return;
|
||||||
<th class="text-end">Count</th>
|
var data = JSON.parse(el.textContent);
|
||||||
<th>Distribution</th>
|
if (!data.length) return;
|
||||||
</tr>
|
var canvas = document.getElementById('moodDistChart');
|
||||||
</thead>
|
var ctx = canvas.getContext('2d');
|
||||||
<tbody>
|
var rowH = 34;
|
||||||
{% with max=data.moods.0.count %}
|
var labelW = 140;
|
||||||
{% for entry in data.moods %}
|
var barLeft = labelW + 8;
|
||||||
<tr>
|
var barRight = canvas.width - 80;
|
||||||
<td>{{ entry.mood }}</td>
|
var barMaxW = barRight - barLeft;
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
var padTop = 8;
|
||||||
<td style="width: 40%;">
|
|
||||||
{% if max > 0 %}
|
canvas.height = data.length * rowH + padTop;
|
||||||
<div class="progress" style="height: 12px;">
|
|
||||||
<div class="progress-bar" role="progressbar"
|
var maxCount = data[0].count;
|
||||||
style="width: {% widthratio entry.count max 100 %}%;"
|
|
||||||
aria-valuenow="{{ entry.count }}"
|
function roundRect(x, y, w, h, r) {
|
||||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
ctx.beginPath();
|
||||||
</div>
|
ctx.moveTo(x + r, y);
|
||||||
</div>
|
ctx.lineTo(x + w - r, y);
|
||||||
{% endif %}
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
</td>
|
ctx.lineTo(x + w, y + h - r);
|
||||||
</tr>
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
{% endfor %}
|
ctx.lineTo(x + r, y + h);
|
||||||
{% endwith %}
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
</tbody>
|
ctx.lineTo(x, y + r);
|
||||||
</table>
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
</div>
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(function(entry, i) {
|
||||||
|
var y = padTop + i * rowH;
|
||||||
|
var pct = entry.count / maxCount;
|
||||||
|
var barW = pct * barMaxW;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(entry.mood, labelW - 6, y + rowH / 2);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f3f4f6';
|
||||||
|
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
var hue = 120 * (1 - i / (data.length - 1 || 1));
|
||||||
|
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
|
||||||
|
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = 'bold 13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('' + entry.count, barRight + 6, y + rowH / 2);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No mood distribution data found.</p>
|
<p class="text-muted">No mood distribution data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,37 +1,105 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.trajectory %}
|
{% if data.trajectory %}
|
||||||
<div class="table-responsive">
|
{{ data.trajectory|json_script:"mood-trajectory-data" }}
|
||||||
<table class="table table-striped table-sm">
|
<div class="d-flex flex-column align-items-center">
|
||||||
<thead>
|
<canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
|
||||||
<tr>
|
<div class="d-flex gap-4 mt-2 small text-muted">
|
||||||
<th>Date</th>
|
<span>← Earlier</span>
|
||||||
<th class="text-end">Avg Quality</th>
|
<span>Later →</span>
|
||||||
<th class="text-end">Check-ins</th>
|
</div>
|
||||||
<th>Mood Bar</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for entry in data.trajectory %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ entry.date }}</td>
|
|
||||||
<td class="text-end">{{ entry.avg_quality }}</td>
|
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
|
||||||
<td style="width: 40%;">
|
|
||||||
<div class="progress" style="height: 16px;">
|
|
||||||
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
|
|
||||||
role="progressbar"
|
|
||||||
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
|
|
||||||
aria-valuenow="{{ entry.avg_quality }}"
|
|
||||||
aria-valuemin="1" aria-valuemax="7">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var el = document.getElementById('mood-trajectory-data');
|
||||||
|
if (!el) return;
|
||||||
|
var data = JSON.parse(el.textContent);
|
||||||
|
if (!data.length) return;
|
||||||
|
var canvas = document.getElementById('moodTrajectoryChart');
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var W = canvas.width, H = canvas.height;
|
||||||
|
var pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
var plotW = W - pad.left - pad.right;
|
||||||
|
var plotH = H - pad.top - pad.bottom;
|
||||||
|
|
||||||
|
var yMin = 1, yMax = 7;
|
||||||
|
var yRange = yMax - yMin;
|
||||||
|
var maxCount = data.reduce(function(m, d) { return Math.max(m, d.count); }, 0);
|
||||||
|
|
||||||
|
function xPos(i) {
|
||||||
|
return pad.left + (i / (data.length - 1 || 1)) * plotW;
|
||||||
|
}
|
||||||
|
function yPos(val) {
|
||||||
|
return pad.top + (1 - (val - yMin) / yRange) * plotH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background grid
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (var q = 1; q <= 7; q++) {
|
||||||
|
var y = yPos(q);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.left, y);
|
||||||
|
ctx.lineTo(W - pad.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(q.toFixed(1), pad.left - 5, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference line at neutral (4)
|
||||||
|
var neutralY = yPos(4);
|
||||||
|
ctx.strokeStyle = '#d1d5db';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.left, neutralY);
|
||||||
|
ctx.lineTo(W - pad.right, neutralY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('neutral', W - pad.right + 4, neutralY + 4);
|
||||||
|
|
||||||
|
// Line chart
|
||||||
|
ctx.beginPath();
|
||||||
|
data.forEach(function(d, i) {
|
||||||
|
var x = xPos(i);
|
||||||
|
var y = yPos(d.avg_quality);
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.strokeStyle = '#6366f1';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Gradient fill below the line
|
||||||
|
var gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom);
|
||||||
|
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.25)');
|
||||||
|
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
|
||||||
|
ctx.lineTo(xPos(data.length - 1), yPos(yMin));
|
||||||
|
ctx.lineTo(xPos(0), yPos(yMin));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Dots
|
||||||
|
data.forEach(function(d, i) {
|
||||||
|
var x = xPos(i);
|
||||||
|
var y = yPos(d.avg_quality);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#6366f1';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No mood check-in data found.</p>
|
<p class="text-muted">No mood check-in data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,50 +1,90 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.hours %}
|
{% if data.hours %}
|
||||||
<div class="table-responsive">
|
{{ data.hours|json_script:"peak-hours-data" }}
|
||||||
<table class="table table-striped table-sm">
|
<div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
|
||||||
<thead>
|
<div>
|
||||||
<tr>
|
<canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
|
||||||
<th>Hour</th>
|
</div>
|
||||||
<th class="text-end">Scrobbles</th>
|
<div class="table-responsive" style="max-height:360px; overflow-y:auto;">
|
||||||
<th>Distribution</th>
|
<table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th style="width:14px; padding-right:0;"></th>
|
||||||
{% with total=data.hours|dictsortreversed:"count"|first %}
|
<th>Hour</th>
|
||||||
{% with max_count=total.count %}
|
<th class="text-end">Scrobbles</th>
|
||||||
{% for entry in data.hours %}
|
</tr>
|
||||||
<tr>
|
</thead>
|
||||||
<td>
|
<tbody>
|
||||||
{% if entry.hour == 0 %}
|
{% for entry in data.hours %}
|
||||||
12 AM
|
<tr>
|
||||||
{% elif entry.hour < 12 %}
|
<td class="p-1 text-center">
|
||||||
{{ entry.hour }} AM
|
<span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
|
||||||
{% elif entry.hour == 12 %}
|
</td>
|
||||||
12 PM
|
<td>
|
||||||
{% else %}
|
{% if entry.hour == 0 %}
|
||||||
{{ entry.hour|add:"-12" }} PM
|
12 AM
|
||||||
{% endif %}
|
{% elif entry.hour < 12 %}
|
||||||
</td>
|
{{ entry.hour }} AM
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
{% elif entry.hour == 12 %}
|
||||||
<td style="width: 40%;">
|
12 PM
|
||||||
{% if max_count > 0 %}
|
{% else %}
|
||||||
<div class="progress" style="height: 12px;">
|
{{ entry.hour|add:"-12" }} PM
|
||||||
<div class="progress-bar" role="progressbar"
|
{% endif %}
|
||||||
style="width: {{ entry.count|floatformat:0 }}%;"
|
</td>
|
||||||
aria-valuenow="{{ entry.count }}"
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
</tr>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
</tbody>
|
||||||
{% endif %}
|
</table>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var dataEl = document.getElementById('peak-hours-data');
|
||||||
|
if (!dataEl) return;
|
||||||
|
var data = JSON.parse(dataEl.textContent);
|
||||||
|
var total = data.reduce(function(s, h) { return s + h.count; }, 0) || 1;
|
||||||
|
var canvas = document.getElementById('peakHoursChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var cx = canvas.width / 2;
|
||||||
|
var cy = canvas.height / 2;
|
||||||
|
var radius = Math.min(cx, cy) - 10;
|
||||||
|
var startAngle = -Math.PI / 2;
|
||||||
|
|
||||||
|
var minCount = data.reduce(function(m, h) { return Math.min(m, h.count); }, Infinity);
|
||||||
|
var maxCount = data.reduce(function(m, h) { return Math.max(m, h.count); }, 0);
|
||||||
|
var range = maxCount - minCount || 1;
|
||||||
|
|
||||||
|
function countToHue(count) {
|
||||||
|
var t = (count - minCount) / range;
|
||||||
|
return 120 * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(function(entry, i) {
|
||||||
|
var sliceAngle = (entry.count / total) * 2 * Math.PI;
|
||||||
|
var hue = countToHue(entry.count);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = 'hsl(' + hue + ', 70%, 50%)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
startAngle += sliceAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.legend-swatch').forEach(function(el) {
|
||||||
|
var idx = parseInt(el.getAttribute('data-idx'), 10);
|
||||||
|
var entry = data[idx];
|
||||||
|
var hue = countToHue(entry.count);
|
||||||
|
el.style.backgroundColor = 'hsl(' + hue + ', 70%, 50%)';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No activity data found.</p>
|
<p class="text-muted">No activity data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,31 +1,45 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.total and data.total > 0 %}
|
{% if data.total and data.total > 0 %}
|
||||||
<h5>Overall</h5>
|
<div class="mb-4">
|
||||||
<div class="table-responsive mb-4">
|
{% for slug, info in data.categories.items %}
|
||||||
<table class="table table-striped table-sm">
|
{% if forloop.first %}
|
||||||
<thead>
|
<div class="card text-center border-0 bg-light mb-3">
|
||||||
<tr>
|
<div class="card-body py-4">
|
||||||
<th>Category</th>
|
{% if slug == "early_bird" %}
|
||||||
<th class="text-end">Scrobbles</th>
|
<div class="display-1">🌅</div>
|
||||||
<th class="text-end">%</th>
|
{% elif slug == "day_jay" %}
|
||||||
</tr>
|
<div class="display-1">☀️</div>
|
||||||
</thead>
|
{% else %}
|
||||||
<tbody>
|
<div class="display-1">🌙</div>
|
||||||
{% for slug, info in data.categories.items %}
|
{% endif %}
|
||||||
<tr>
|
<h3 class="mt-2">{{ info.label }}</h3>
|
||||||
<td>{{ info.label }}</td>
|
<div class="display-4 fw-bold">{{ info.pct }}%</div>
|
||||||
<td class="text-end">{{ info.count }}</td>
|
<div class="text-muted">{{ info.count }} scrobbles</div>
|
||||||
<td class="text-end">{{ info.pct }}%</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
<tr class="table-secondary">
|
<div class="card text-center border-0 bg-light mb-2">
|
||||||
<td><strong>Total</strong></td>
|
<div class="card-body py-2 d-flex align-items-center justify-content-between px-4">
|
||||||
<td class="text-end"><strong>{{ data.total }}</strong></td>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<td class="text-end"></td>
|
{% if slug == "early_bird" %}
|
||||||
</tr>
|
<span style="font-size:1.5rem;">🌅</span>
|
||||||
</tbody>
|
{% elif slug == "day_jay" %}
|
||||||
</table>
|
<span style="font-size:1.5rem;">☀️</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:1.5rem;">🌙</span>
|
||||||
|
{% endif %}
|
||||||
|
<h5 class="mb-0">{{ info.label }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="fs-4 fw-bold">{{ info.pct }}%</span>
|
||||||
|
<br><small class="text-muted">{{ info.count }} scrobbles</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-center text-muted small mt-2">Total: {{ data.total }} scrobbles across Books, Trails, Birding Locations, and Board Games</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5>By Media Type</h5>
|
<h5>By Media Type</h5>
|
||||||
|
|||||||
@ -1,38 +1,27 @@
|
|||||||
<div class="row">
|
<div class="row g-3">
|
||||||
<div class="col-12">
|
{% if data %}
|
||||||
{% if data %}
|
{% for mt, info in data.items %}
|
||||||
<div class="table-responsive">
|
<div class="col-6 col-md-4 col-lg-3 col-xl-2">
|
||||||
<table class="table table-striped table-sm">
|
<div class="card text-center h-100">
|
||||||
<thead>
|
<div class="card-body d-flex flex-column align-items-center justify-content-center" style="aspect-ratio: 1;">
|
||||||
<tr>
|
{% if info.change_pct > 0 %}
|
||||||
<th>Media Type</th>
|
<div class="display-3 lh-1 text-success">↑</div>
|
||||||
<th class="text-end">Recent ({{ current_period_label }})</th>
|
<div class="fs-6 text-success mt-1">+{{ info.change_pct }}%</div>
|
||||||
<th class="text-end">Previous ({{ current_period_label }})</th>
|
{% elif info.change_pct < 0 %}
|
||||||
<th class="text-end">Change</th>
|
<div class="display-3 lh-1 text-danger">↓</div>
|
||||||
</tr>
|
<div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>
|
||||||
</thead>
|
{% else %}
|
||||||
<tbody>
|
<div class="display-3 lh-1 text-muted">→</div>
|
||||||
{% for mt, info in data.items %}
|
<div class="fs-6 text-muted mt-1">0%</div>
|
||||||
<tr>
|
{% endif %}
|
||||||
<td>{{ mt }}</td>
|
<div class="fw-bold mt-2">{{ mt }}</div>
|
||||||
<td class="text-end">{{ info.recent }}</td>
|
</div>
|
||||||
<td class="text-end">{{ info.previous }}</td>
|
</div>
|
||||||
<td class="text-end">
|
|
||||||
{% if info.change_pct > 0 %}
|
|
||||||
<span class="text-success">+{{ info.change_pct }}%</span>
|
|
||||||
{% elif info.change_pct < 0 %}
|
|
||||||
<span class="text-danger">{{ info.change_pct }}%</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">0%</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<p class="text-muted">No trending data found.</p>
|
{% else %}
|
||||||
{% endif %}
|
<div class="col-12">
|
||||||
</div>
|
<p class="text-muted">No trending data found.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,18 +59,21 @@ def compute_time_of_day_categories(user, period="last_30"):
|
|||||||
if slug:
|
if slug:
|
||||||
cat_counts[slug] += count
|
cat_counts[slug] += count
|
||||||
mt_total += count
|
mt_total += count
|
||||||
by_media_type[mt] = {
|
mt_categories = {}
|
||||||
"total": mt_total,
|
|
||||||
"categories": {},
|
|
||||||
}
|
|
||||||
for slug in CATEGORIES:
|
for slug in CATEGORIES:
|
||||||
c = cat_counts[slug]
|
c = cat_counts[slug]
|
||||||
by_media_type[mt]["categories"][slug] = {
|
mt_categories[slug] = {
|
||||||
"count": c,
|
"count": c,
|
||||||
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
|
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
|
||||||
"label": CATEGORIES[slug]["label"],
|
"label": CATEGORIES[slug]["label"],
|
||||||
}
|
}
|
||||||
grand_totals[slug] += c
|
grand_totals[slug] += c
|
||||||
|
by_media_type[mt] = {
|
||||||
|
"total": mt_total,
|
||||||
|
"categories": dict(
|
||||||
|
sorted(mt_categories.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||||
|
),
|
||||||
|
}
|
||||||
grand_total += mt_total
|
grand_total += mt_total
|
||||||
|
|
||||||
categories = {}
|
categories = {}
|
||||||
@ -81,6 +84,9 @@ def compute_time_of_day_categories(user, period="last_30"):
|
|||||||
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
|
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
|
||||||
"label": CATEGORIES[slug]["label"],
|
"label": CATEGORIES[slug]["label"],
|
||||||
}
|
}
|
||||||
|
categories = dict(
|
||||||
|
sorted(categories.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
|
|||||||
@ -120,7 +120,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "scrobbles/_top_charts.html" %}
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'charts:maloja-charts' %}" class="btn btn-sm btn-outline-secondary">🎨 Maloja Widgets</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
{% if charts.artist %}
|
{% if charts.artist %}
|
||||||
|
|||||||
45
vrobbler/templates/charts/maloja_charts.html
Normal file
45
vrobbler/templates/charts/maloja_charts.html
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Maloja Widgets{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.container { margin-bottom: 100px; }
|
||||||
|
h2 { padding-top: 20px; }
|
||||||
|
.nav-tabs { cursor: pointer; }
|
||||||
|
.image-wrapper { contain: content; }
|
||||||
|
.image-wrapper :hover { background: rgba(0,0,0,0.3); }
|
||||||
|
.caption {
|
||||||
|
position: fixed; top: 5px; left: 5px;
|
||||||
|
padding: 3px; font-size: 90%;
|
||||||
|
color: white; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.caption-medium {
|
||||||
|
position: fixed; top: 5px; left: 5px;
|
||||||
|
padding: 3px; font-size: 75%;
|
||||||
|
color: white; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.caption-small {
|
||||||
|
position: fixed; top: 5px; left: 5px;
|
||||||
|
padding: 3px; font-size: 60%;
|
||||||
|
color: white; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
{% block grid_view_button %}{% endblock %}
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">← Full Charts</a>
|
||||||
|
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||||
|
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "scrobbles/_top_charts.html" %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user