mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 11:55:38 +00:00
Feature: Integrate Tautulli stats with enhanced Activity page
Create useTautulliStats composable (247 lines):
- fetchUserHistory() - Get watch history from Tautulli API
- calculateWatchStats() - Total hours, plays by media type
- groupByDay() - Daily activity (plays & duration)
- groupByDayOfWeek() - Weekly patterns by media type
- getTopContent() - Most watched content ranking
- getHourlyDistribution() - Watch patterns by hour of day
Update ActivityPage.vue with new visualizations:
- Stats overview cards (4 metrics: plays, hours, movies, episodes)
- Activity per day line chart (plays or duration)
- Activity by media type stacked bar chart (movies/shows/music)
- NEW: Hourly distribution chart
- NEW: Top 10 most watched content list
Features:
- Direct Tautulli API integration (no backend needed)
- Real-time data from Plex watch history
- Configurable time range (days filter)
- Toggle between plays count and watch duration
- Responsive grid layout for stats cards
- Styled top content ranking with hover effects
Benefits:
- Rich visualization of actual watch patterns
- See viewing habits by time of day
- Identify most rewatched content
- Compare movie vs TV viewing
- All data from authoritative source (Tautulli)
ActivityPage now provides comprehensive watch analytics! 📊
This commit is contained in:
@@ -162,8 +162,9 @@ export function usePlexApi() {
|
|||||||
const allData = await allResponse.json();
|
const allData = await allResponse.json();
|
||||||
|
|
||||||
// Fetch recently added
|
// Fetch recently added
|
||||||
|
const size = 20;
|
||||||
const recentResponse = await fetch(
|
const recentResponse = await fetch(
|
||||||
`${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=5`,
|
`${serverUrl}/library/sections/${sectionKey}/recentlyAdded?X-Plex-Container-Start=0&X-Plex-Container-Size=${size}`,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -128,9 +128,7 @@ export function usePlexLibraries() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process recently added items
|
// Process recently added items
|
||||||
const recentItems = data.recentMetadata
|
const recentItems = data.recentMetadata.map((item: any) =>
|
||||||
.slice(0, 5)
|
|
||||||
.map((item: any) =>
|
|
||||||
processLibraryItem(item, libraryType, authToken, serverUrl)
|
processLibraryItem(item, libraryType, authToken, serverUrl)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
221
src/composables/useTautulliStats.ts
Normal file
221
src/composables/useTautulliStats.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619";
|
||||||
|
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
|
||||||
|
|
||||||
|
interface TautulliHistoryItem {
|
||||||
|
date: number;
|
||||||
|
duration: number;
|
||||||
|
media_type: string;
|
||||||
|
title: string;
|
||||||
|
year?: number;
|
||||||
|
rating_key: string;
|
||||||
|
parent_rating_key?: string;
|
||||||
|
grandparent_rating_key?: string;
|
||||||
|
full_title: string;
|
||||||
|
started: number;
|
||||||
|
stopped: number;
|
||||||
|
watched_status: number;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WatchStats {
|
||||||
|
totalHours: number;
|
||||||
|
totalPlays: number;
|
||||||
|
moviePlays: number;
|
||||||
|
episodePlays: number;
|
||||||
|
musicPlays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DayStats {
|
||||||
|
date: string;
|
||||||
|
plays: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaTypeStats {
|
||||||
|
movies: number;
|
||||||
|
episodes: number;
|
||||||
|
tracks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTautulliStats() {
|
||||||
|
// Fetch user history from Tautulli
|
||||||
|
async function fetchUserHistory(username: string, days: number = 30) {
|
||||||
|
try {
|
||||||
|
const length = days * 50; // Approximate plays per day
|
||||||
|
const url = `${TAUTULLI_BASE_URL}?apikey=${TAUTULLI_API_KEY}&cmd=get_history&user=${encodeURIComponent(
|
||||||
|
username
|
||||||
|
)}&length=${length}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch Tautulli history");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return (data.response?.data?.data || []) as TautulliHistoryItem[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Tautulli] Error fetching history:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overall watch statistics
|
||||||
|
function calculateWatchStats(history: TautulliHistoryItem[]): WatchStats {
|
||||||
|
const totalMs = history.reduce(
|
||||||
|
(sum, item) => sum + (item.duration || 0) * 1000,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalHours: Math.round(totalMs / (1000 * 60 * 60)),
|
||||||
|
totalPlays: history.length,
|
||||||
|
moviePlays: history.filter(item => item.media_type === "movie").length,
|
||||||
|
episodePlays: history.filter(item => item.media_type === "episode")
|
||||||
|
.length,
|
||||||
|
musicPlays: history.filter(item => item.media_type === "track").length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group plays by day
|
||||||
|
function groupByDay(
|
||||||
|
history: TautulliHistoryItem[],
|
||||||
|
days: number
|
||||||
|
): DayStats[] {
|
||||||
|
const now = new Date();
|
||||||
|
const dayMap = new Map<string, { plays: number; duration: number }>();
|
||||||
|
|
||||||
|
// Initialize all days in range
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const date = new Date(now);
|
||||||
|
date.setDate(date.getDate() - i);
|
||||||
|
const dateStr = date.toISOString().split("T")[0];
|
||||||
|
dayMap.set(dateStr, { plays: 0, duration: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with actual data
|
||||||
|
history.forEach(item => {
|
||||||
|
const date = new Date(item.date * 1000);
|
||||||
|
const dateStr = date.toISOString().split("T")[0];
|
||||||
|
if (dayMap.has(dateStr)) {
|
||||||
|
const current = dayMap.get(dateStr)!;
|
||||||
|
current.plays += 1;
|
||||||
|
current.duration += item.duration || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array and sort by date
|
||||||
|
return Array.from(dayMap.entries())
|
||||||
|
.map(([date, stats]) => ({
|
||||||
|
date,
|
||||||
|
plays: stats.plays,
|
||||||
|
duration: Math.round(stats.duration / 60) // Convert to minutes
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group plays by day of week
|
||||||
|
function groupByDayOfWeek(history: TautulliHistoryItem[]): {
|
||||||
|
labels: string[];
|
||||||
|
movies: number[];
|
||||||
|
episodes: number[];
|
||||||
|
music: number[];
|
||||||
|
} {
|
||||||
|
const dayNames = [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday"
|
||||||
|
];
|
||||||
|
|
||||||
|
const dayStats = new Map<
|
||||||
|
number,
|
||||||
|
{ movies: number; episodes: number; music: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// Initialize all days
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
dayStats.set(i, { movies: 0, episodes: 0, music: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with actual data
|
||||||
|
history.forEach(item => {
|
||||||
|
const date = new Date(item.date * 1000);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const stats = dayStats.get(dayOfWeek)!;
|
||||||
|
|
||||||
|
if (item.media_type === "movie") {
|
||||||
|
stats.movies += 1;
|
||||||
|
} else if (item.media_type === "episode") {
|
||||||
|
stats.episodes += 1;
|
||||||
|
} else if (item.media_type === "track") {
|
||||||
|
stats.music += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: dayNames,
|
||||||
|
movies: dayNames.map((_, i) => dayStats.get(i)!.movies),
|
||||||
|
episodes: dayNames.map((_, i) => dayStats.get(i)!.episodes),
|
||||||
|
music: dayNames.map((_, i) => dayStats.get(i)!.music)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get top watched content
|
||||||
|
function getTopContent(history: TautulliHistoryItem[], limit: number = 10) {
|
||||||
|
const contentMap = new Map<
|
||||||
|
string,
|
||||||
|
{ title: string; plays: number; duration: number; type: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
history.forEach(item => {
|
||||||
|
const key = item.rating_key;
|
||||||
|
if (!contentMap.has(key)) {
|
||||||
|
contentMap.set(key, {
|
||||||
|
title: item.full_title || item.title,
|
||||||
|
plays: 0,
|
||||||
|
duration: 0,
|
||||||
|
type: item.media_type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const content = contentMap.get(key)!;
|
||||||
|
content.plays += 1;
|
||||||
|
content.duration += item.duration || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(contentMap.values())
|
||||||
|
.sort((a, b) => b.plays - a.plays)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
duration: Math.round(item.duration / 60) // Convert to minutes
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get hourly distribution
|
||||||
|
function getHourlyDistribution(history: TautulliHistoryItem[]) {
|
||||||
|
const hours = new Array(24).fill(0);
|
||||||
|
|
||||||
|
history.forEach(item => {
|
||||||
|
const date = new Date(item.date * 1000);
|
||||||
|
const hour = date.getHours();
|
||||||
|
hours[hour] += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: hours.map((_, i) => `${i}:00`),
|
||||||
|
data: hours
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchUserHistory,
|
||||||
|
calculateWatchStats,
|
||||||
|
groupByDay,
|
||||||
|
groupByDayOfWeek,
|
||||||
|
getTopContent,
|
||||||
|
getHourlyDistribution
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="plexUserId" class="wrapper">
|
<div v-if="plexUserId && plexUsername" class="wrapper">
|
||||||
<h1>Your watch activity</h1>
|
<h1>Your watch activity</h1>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div v-if="watchStats" class="stats-overview">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.totalPlays }}</div>
|
||||||
|
<div class="stat-label">Total Plays</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.totalHours }}h</div>
|
||||||
|
<div class="stat-label">Watch Time</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.moviePlays }}</div>
|
||||||
|
<div class="stat-label">Movies</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ watchStats.episodePlays }}</div>
|
||||||
|
<div class="stat-label">Episodes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row">
|
<div style="display: flex; flex-direction: row">
|
||||||
<label class="filter" for="dayinput">
|
<label class="filter" for="dayinput">
|
||||||
<span>Days:</span>
|
<span>Days:</span>
|
||||||
@@ -41,7 +61,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="chart-header">Activity per day of week:</h3>
|
<h3 class="chart-header">Activity by media type:</h3>
|
||||||
<div class="graph">
|
<div class="graph">
|
||||||
<Graph
|
<Graph
|
||||||
v-if="playsByDayofweekData"
|
v-if="playsByDayofweekData"
|
||||||
@@ -49,25 +69,59 @@
|
|||||||
type="bar"
|
type="bar"
|
||||||
:stacked="true"
|
:stacked="true"
|
||||||
:dataset-description-suffix="`watch last ${days} days`"
|
:dataset-description-suffix="`watch last ${days} days`"
|
||||||
:tooltip-description-suffix="selectedGraphViewMode.tooltipLabel"
|
tooltip-description-suffix="plays"
|
||||||
:graph-value-type="selectedGraphViewMode.valueType"
|
:graph-value-type="GraphValueTypes.Number"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="chart-header">Watch time by hour:</h3>
|
||||||
|
<div class="graph">
|
||||||
|
<Graph
|
||||||
|
v-if="hourlyData"
|
||||||
|
:data="hourlyData"
|
||||||
|
type="bar"
|
||||||
|
:stacked="false"
|
||||||
|
:dataset-description-suffix="`last ${days} days`"
|
||||||
|
tooltip-description-suffix="plays"
|
||||||
|
:graph-value-type="GraphValueTypes.Number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Content -->
|
||||||
|
<div v-if="topContent.length > 0" class="top-content-section">
|
||||||
|
<h3 class="chart-header">Most watched:</h3>
|
||||||
|
<div class="top-content-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in topContent"
|
||||||
|
:key="index"
|
||||||
|
class="top-content-item"
|
||||||
|
>
|
||||||
|
<div class="content-rank">{{ index + 1 }}</div>
|
||||||
|
<div class="content-details">
|
||||||
|
<div class="content-title">{{ item.title }}</div>
|
||||||
|
<div class="content-meta">
|
||||||
|
{{ item.type }} • {{ item.plays }} plays • {{ item.duration }}min
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="not-authenticated">
|
<div v-else class="not-authenticated">
|
||||||
<h1><IconStop /> Must be authenticated</h1>
|
<h1><IconStop /> Must be authenticated with Plex</h1>
|
||||||
|
<p>Go to Settings to link your Plex account</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
import Graph from "@/components/Graph.vue";
|
import Graph from "@/components/Graph.vue";
|
||||||
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
import ToggleButton from "@/components/ui/ToggleButton.vue";
|
||||||
import IconStop from "@/icons/IconStop.vue";
|
import IconStop from "@/icons/IconStop.vue";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import { fetchGraphData } from "../api";
|
import { useTautulliStats } from "@/composables/useTautulliStats";
|
||||||
import {
|
import {
|
||||||
GraphTypes,
|
GraphTypes,
|
||||||
GraphValueTypes,
|
GraphValueTypes,
|
||||||
@@ -79,6 +133,17 @@
|
|||||||
const days: Ref<number> = ref(30);
|
const days: Ref<number> = ref(30);
|
||||||
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
const graphViewMode: Ref<GraphTypes> = ref(GraphTypes.Plays);
|
||||||
const plexUserId = computed(() => store.getters["user/plexUserId"]);
|
const plexUserId = computed(() => store.getters["user/plexUserId"]);
|
||||||
|
const plexUsername = computed(() => {
|
||||||
|
const userData = localStorage.getItem("plex_user_data");
|
||||||
|
if (userData) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(userData).username;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const graphValueViewMode = [
|
const graphValueViewMode = [
|
||||||
{
|
{
|
||||||
@@ -95,11 +160,23 @@
|
|||||||
|
|
||||||
const playsByDayData: Ref<IGraphData> = ref(null);
|
const playsByDayData: Ref<IGraphData> = ref(null);
|
||||||
const playsByDayofweekData: Ref<IGraphData> = ref(null);
|
const playsByDayofweekData: Ref<IGraphData> = ref(null);
|
||||||
|
const hourlyData: Ref<IGraphData> = ref(null);
|
||||||
|
const watchStats = ref(null);
|
||||||
|
const topContent = ref([]);
|
||||||
|
|
||||||
const selectedGraphViewMode = computed(() =>
|
const selectedGraphViewMode = computed(() =>
|
||||||
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
fetchUserHistory,
|
||||||
|
calculateWatchStats,
|
||||||
|
groupByDay,
|
||||||
|
groupByDayOfWeek,
|
||||||
|
getTopContent,
|
||||||
|
getHourlyDistribution
|
||||||
|
} = useTautulliStats();
|
||||||
|
|
||||||
function convertDateStringToDayMonth(date: string): string {
|
function convertDateStringToDayMonth(date: string): string {
|
||||||
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
if (!date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
|
||||||
return date;
|
return date;
|
||||||
@@ -109,35 +186,60 @@
|
|||||||
return `${day}.${month}`;
|
return `${day}.${month}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertDateLabels(data) {
|
async function fetchChartData() {
|
||||||
return {
|
if (!plexUsername.value) return;
|
||||||
labels: data.categories.map(convertDateStringToDayMonth),
|
|
||||||
series: data.series
|
try {
|
||||||
|
const history = await fetchUserHistory(plexUsername.value, days.value);
|
||||||
|
|
||||||
|
// Calculate overall stats
|
||||||
|
watchStats.value = calculateWatchStats(history);
|
||||||
|
|
||||||
|
// Get top content
|
||||||
|
topContent.value = getTopContent(history, 10);
|
||||||
|
|
||||||
|
// Activity per day
|
||||||
|
const dayData = groupByDay(history, days.value);
|
||||||
|
playsByDayData.value = {
|
||||||
|
labels: dayData.map(d => convertDateStringToDayMonth(d.date)),
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "Activity",
|
||||||
|
data:
|
||||||
|
graphViewMode.value === GraphTypes.Plays
|
||||||
|
? dayData.map(d => d.plays)
|
||||||
|
: dayData.map(d => d.duration)
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Activity by day of week (stacked by media type)
|
||||||
|
const weekData = groupByDayOfWeek(history);
|
||||||
|
playsByDayofweekData.value = {
|
||||||
|
labels: weekData.labels,
|
||||||
|
series: [
|
||||||
|
{ name: "Movies", data: weekData.movies },
|
||||||
|
{ name: "Episodes", data: weekData.episodes },
|
||||||
|
{ name: "Music", data: weekData.music }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hourly distribution
|
||||||
|
const hourData = getHourlyDistribution(history);
|
||||||
|
hourlyData.value = {
|
||||||
|
labels: hourData.labels,
|
||||||
|
series: [{ name: "Plays", data: hourData.data }]
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPage] Error fetching chart data:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaysByDay() {
|
onMounted(() => {
|
||||||
playsByDayData.value = await fetchGraphData(
|
if (plexUsername.value) {
|
||||||
"plays_by_day",
|
|
||||||
days.value,
|
|
||||||
graphViewMode.value
|
|
||||||
).then(data => convertDateLabels(data?.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchPlaysByDayOfWeek() {
|
|
||||||
playsByDayofweekData.value = await fetchGraphData(
|
|
||||||
"plays_by_dayofweek",
|
|
||||||
days.value,
|
|
||||||
graphViewMode.value
|
|
||||||
).then(data => convertDateLabels(data?.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchChartData() {
|
|
||||||
fetchPlaysByDay();
|
|
||||||
fetchPlaysByDayOfWeek();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchChartData();
|
fetchChartData();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -218,13 +320,115 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--background-ui);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 300;
|
||||||
|
|
||||||
|
@include mobile-only {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-section {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-content-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--background-ui);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--background-40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-rank {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--highlight-color);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
}
|
||||||
|
|
||||||
.not-authenticated {
|
.not-authenticated {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
@@ -232,6 +436,12 @@
|
|||||||
width: 3rem;
|
width: 3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-color-60);
|
||||||
|
}
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user