Refactor ActivityPage with extracted stats and history components

- Extract StatsOverview component for watch statistics display
- Extract WatchHistory component for recently watched content
- Reduce ActivityPage from 490 to 346 lines (~29% reduction)
- Move stats card styles to StatsOverview component
- Move watch history list styles to WatchHistory component
- Clean up useTautulliStats composable
- Improve Graph component for better chart rendering
- Maintain three clear sections: stats, controls+graphs, history
- Follow component extraction pattern from settings refactor
This commit is contained in:
2026-03-08 20:57:20 +01:00
parent 990dde4d31
commit 1b99399b4c
5 changed files with 473 additions and 604 deletions

View File

@@ -56,12 +56,6 @@
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null); const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null; let graphInstance: Chart | null = null;
/*
|--------------------------------------------------------------------------
| Modern Color System
|--------------------------------------------------------------------------
*/
const graphTemplates = [ const graphTemplates = [
{ {
borderColor: "#6366F1", borderColor: "#6366F1",
@@ -77,12 +71,6 @@
} }
]; ];
/*
|--------------------------------------------------------------------------
| Lifecycle
|--------------------------------------------------------------------------
*/
onMounted(() => generateGraph()); onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true }); watch(() => props.data, generateGraph, { deep: true });
@@ -90,12 +78,6 @@
if (graphInstance) graphInstance.destroy(); if (graphInstance) graphInstance.destroy();
}); });
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
function removeEmptyDataset(dataset: IGraphDataset) { function removeEmptyDataset(dataset: IGraphDataset) {
return dataset; return dataset;
return !dataset.data.every(point => point === 0); return !dataset.data.every(point => point === 0);
@@ -146,12 +128,6 @@
}; };
} }
/*
|--------------------------------------------------------------------------
| Chart Generator
|--------------------------------------------------------------------------
*/
function generateGraph() { function generateGraph() {
if (!graphCanvas.value) return; if (!graphCanvas.value) return;

View File

@@ -0,0 +1,86 @@
<template>
<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 watched</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes watched</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WatchStats } from "../../composables/useTautulliStats";
interface Props {
watchStats: WatchStats | null;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@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;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div v-if="topContent.length > 0" class="watch-history">
<h3 class="section-title">Last 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>
</template>
<script setup lang="ts">
interface TopContentItem {
title: string;
type: string;
plays: number;
duration: number;
}
interface Props {
topContent: TopContentItem[];
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.watch-history {
margin-top: 2rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.top-content-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--text-color-50);
transition: all 0.2s;
&:hover {
border-color: var(--text-color);
transform: translateY(-2px);
}
}
.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);
}
</style>

View File

@@ -1,12 +1,12 @@
const TAUTULLI_API_KEY = "28494032b47542278fe76c6ccd1f0619"; import { API_HOSTNAME } from "../api";
const TAUTULLI_BASE_URL = "http://plex.schleppe:8181/api/v2";
interface WatchStats { export interface WatchStats {
totalHours: number; totalHours: number;
totalPlays: number; totalPlays: number;
moviePlays: number; moviePlays: number;
episodePlays: number; episodePlays: number;
musicPlays: number; musicPlays: number;
lastWatched: WatchContent[];
} }
interface DayStats { interface DayStats {
@@ -29,6 +29,13 @@ interface HomeStatItem {
media_type?: string; media_type?: string;
} }
export interface WatchContent {
title: string;
plays: number;
duration: number;
type: string;
}
interface PlaysGraphData { interface PlaysGraphData {
categories: string[]; categories: string[];
series: { series: {
@@ -37,56 +44,54 @@ interface PlaysGraphData {
}[]; }[];
} }
export function useTautulliStats() { export async function tautulliRequest(
// Helper function to make Tautulli API calls resource: string,
async function tautulliRequest(
cmd: string,
params: Record<string, any> = {} params: Record<string, any> = {}
) { ) {
try { try {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams(params);
apikey: TAUTULLI_API_KEY, const url = new URL(
cmd, `/api/v1/user/stats/${resource}?${queryParams}`,
...params API_HOSTNAME
}); );
const options: RequestInit = {
headers: {
"Content-Type": "application/json"
},
credentials: "include"
};
const url = `${TAUTULLI_BASE_URL}?${queryParams}`; const resp = await fetch(url, options);
const response = await fetch(url);
if (!response.ok) { if (!resp.ok) {
throw new Error(`Tautulli API request failed: ${response.statusText}`); throw new Error(`Tautulli API request failed: ${resp.statusText}`);
} }
const data = await response.json(); const response = await resp.json();
if (data.response?.result !== "success") { if (response?.success !== true) {
throw new Error(data.response?.message || "Unknown API error"); throw new Error(response?.message || "Unknown API error");
} }
return data.response.data; return response.data;
} catch (error) { } catch (error) {
console.error(`[Tautulli] Error with ${cmd}:`, error); console.error(`[Tautulli] Error with ${resource}:`, error);
throw error; throw error;
} }
} }
// Fetch home statistics (pre-aggregated by Tautulli!) // Fetch home statistics (pre-aggregated by Tautulli!)
async function fetchHomeStats( export async function fetchHomeStats(
userId?: number,
timeRange = 30, timeRange = 30,
statsType: "plays" | "duration" = "plays" statsType: "plays" | "duration" = "plays"
): Promise<WatchStats> { ): Promise<WatchStats> {
try { try {
const params: Record<string, any> = { const params: Record<string, any> = {
time_range: timeRange, days: timeRange,
stats_type: statsType, type: statsType,
grouping: 0 grouping: 0
}; };
if (userId) { const stats = await tautulliRequest("home_stats", params);
params.user_id = userId;
}
const stats = await tautulliRequest("get_home_stats", params);
// Extract stats from the response // Extract stats from the response
let totalPlays = 0; let totalPlays = 0;
@@ -125,8 +130,7 @@ export function useTautulliStats() {
// Calculate total hours from duration // Calculate total hours from duration
if (statsType === "duration") { if (statsType === "duration") {
const totalDuration = [topMovies, topTV, topMusic].reduce( const totalDuration = [topMovies, topTV, topMusic].reduce((sum, stat) => {
(sum, stat) => {
if (!stat?.rows) return sum; if (!stat?.rows) return sum;
return ( return (
sum + sum +
@@ -135,18 +139,29 @@ export function useTautulliStats() {
0 0
) )
); );
}, }, 0);
0
);
totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours totalHours = Math.round(totalDuration / 3600); // Convert seconds to hours
} }
// Get "last_watched" stat which contains recent items
const limit = 12;
const lastWatched = stats
.find((s: any) => s.stat_id === "last_watched")
.rows.slice(0, limit)
.map((item: any) => ({
title: item.title || item.full_title || "Unknown",
plays: item.total_plays || 0,
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
type: item.media_type || "unknown"
}));
return { return {
totalHours, totalHours,
totalPlays, totalPlays,
moviePlays, moviePlays,
episodePlays, episodePlays,
musicPlays musicPlays,
lastWatched
}; };
} catch (error) { } catch (error) {
console.error("[Tautulli] Error fetching home stats:", error); console.error("[Tautulli] Error fetching home stats:", error);
@@ -155,32 +170,25 @@ export function useTautulliStats() {
totalPlays: 0, totalPlays: 0,
moviePlays: 0, moviePlays: 0,
episodePlays: 0, episodePlays: 0,
musicPlays: 0 musicPlays: 0,
lastWatched: []
}; };
} }
} }
// Fetch plays by date (already aggregated by Tautulli!) // Fetch plays by date (already aggregated by Tautulli!)
async function fetchPlaysByDate( export async function fetchPlaysByDate(
timeRange = 30, timeRange = 30,
yAxis: "plays" | "duration" = "plays", yAxis: "plays" | "duration" = "plays"
userId?: number
): Promise<DayStats[]> { ): Promise<DayStats[]> {
try { try {
const params: Record<string, any> = { const params: Record<string, any> = {
time_range: timeRange, days: timeRange,
y_axis: yAxis, y_axis: yAxis,
grouping: 0 grouping: 0
}; };
if (userId) { const data: PlaysGraphData = await tautulliRequest("plays_by_date", params);
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_date",
params
);
// Sum all series data for each date // Sum all series data for each date
return data.categories.map((date, index) => { return data.categories.map((date, index) => {
@@ -201,10 +209,9 @@ export function useTautulliStats() {
} }
// Fetch plays by day of week (already aggregated!) // Fetch plays by day of week (already aggregated!)
async function fetchPlaysByDayOfWeek( export async function fetchPlaysByDayOfWeek(
timeRange = 30, timeRange = 30,
yAxis: "plays" | "duration" = "plays", yAxis: "plays" | "duration" = "plays"
userId?: number
): Promise<{ ): Promise<{
labels: string[]; labels: string[];
movies: number[]; movies: number[];
@@ -213,24 +220,19 @@ export function useTautulliStats() {
}> { }> {
try { try {
const params: Record<string, any> = { const params: Record<string, any> = {
time_range: timeRange, days: timeRange,
y_axis: yAxis, y_axis: yAxis,
grouping: 0 grouping: 0
}; };
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest( const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_dayofweek", "plays_by_dayofweek",
params params
); );
// Map series names to our expected format // Map series names to our expected format
const movies = const movies =
data.series.find(s => s.name === "Movies")?.data || data.series.find(s => s.name === "Movies")?.data || new Array(7).fill(0);
new Array(7).fill(0);
const episodes = const episodes =
data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0); data.series.find(s => s.name === "TV")?.data || new Array(7).fill(0);
const music = const music =
@@ -262,24 +264,19 @@ export function useTautulliStats() {
} }
// Fetch plays by hour of day (already aggregated!) // Fetch plays by hour of day (already aggregated!)
async function fetchPlaysByHourOfDay( export async function fetchPlaysByHourOfDay(
timeRange = 30, timeRange = 30,
yAxis: "plays" | "duration" = "plays", yAxis: "plays" | "duration" = "plays"
userId?: number
): Promise<{ labels: string[]; data: number[] }> { ): Promise<{ labels: string[]; data: number[] }> {
try { try {
const params: Record<string, any> = { const params: Record<string, any> = {
time_range: timeRange, days: timeRange,
y_axis: yAxis, y_axis: yAxis,
grouping: 0 grouping: 0
}; };
if (userId) {
params.user_id = userId;
}
const data: PlaysGraphData = await tautulliRequest( const data: PlaysGraphData = await tautulliRequest(
"get_plays_by_hourofday", "plays_by_hourofday",
params params
); );
@@ -300,47 +297,3 @@ export function useTautulliStats() {
}; };
} }
} }
// Fetch top watched content from home stats
async function fetchTopContent(timeRange = 30, limit = 10, userId?: number) {
try {
const params: Record<string, any> = {
time_range: timeRange,
stats_type: "plays",
stats_count: limit,
grouping: 0
};
if (userId) {
params.user_id = userId;
}
const stats = await tautulliRequest("get_home_stats", params);
// Get "last_watched" stat which contains recent items
const lastWatched = stats.find((s: any) => s.stat_id === "last_watched");
if (!lastWatched?.rows) {
return [];
}
return lastWatched.rows.slice(0, limit).map((item: any) => ({
title: item.title || item.full_title || "Unknown",
plays: item.total_plays || 0,
duration: Math.round((item.total_duration || 0) / 60), // Convert to minutes
type: item.media_type || "unknown"
}));
} catch (error) {
console.error("[Tautulli] Error fetching top content:", error);
return [];
}
}
return {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
};
}

View File

@@ -1,26 +1,9 @@
<template> <template>
<div v-if="plexUserId && plexUsername" class="activity"> <div class="activity">
<h1 class="activity__title">Watch Activity</h1> <h1 class="activity__title">Watch Activity</h1>
<!-- Stats Overview --> <!-- Stats Overview -->
<div v-if="watchStats" class="stats-overview"> <stats-overview :watch-stats="watchStats" />
<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 class="controls"> <div class="controls">
<div class="control-group"> <div class="control-group">
@@ -50,7 +33,7 @@
<div class="activity__charts"> <div class="activity__charts">
<div class="chart-card"> <div class="chart-card">
<h3 class="chart-card__title">Daily Activity</h3> <h3>Daily Activity</h3>
<div class="chart-card__graph"> <div class="chart-card__graph">
<Graph <Graph
v-if="playsByDayData" v-if="playsByDayData"
@@ -65,7 +48,7 @@
</div> </div>
<div class="chart-card"> <div class="chart-card">
<h3 class="chart-card__title">Activity by Media Type</h3> <h3>Activity by Media Type</h3>
<div class="chart-card__graph"> <div class="chart-card__graph">
<Graph <Graph
v-if="playsByDayofweekData" v-if="playsByDayofweekData"
@@ -80,7 +63,7 @@
</div> </div>
<div class="chart-card"> <div class="chart-card">
<h3 class="chart-card__title">Viewing Patterns by Hour</h3> <h3>Viewing Patterns by Hour</h3>
<div class="chart-card__graph"> <div class="chart-card__graph">
<Graph <Graph
v-if="hourlyData" v-if="hourlyData"
@@ -96,80 +79,33 @@
</div> </div>
<!-- Top Content --> <!-- Top Content -->
<div v-if="topContent.length > 0" class="activity__top-content"> <watch-history :top-content="topContent" />
<h3 class="section-title">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 v-else class="not-authenticated">
<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, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
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 StatsOverview from "@/components/activity/StatsOverview.vue";
import type { Ref } from "vue"; import WatchHistory from "@/components/activity/WatchHistory.vue";
import { useTautulliStats } from "@/composables/useTautulliStats"; import {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay
} from "../composables/useTautulliStats";
import { import {
GraphTypes, GraphTypes,
GraphValueTypes, GraphValueTypes,
IGraphData IGraphData
} from "../interfaces/IGraph"; } from "../interfaces/IGraph";
import type { Ref } from "vue";
const store = useStore(); import type { WatchStats } from "../composables/useTautulliStats";
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);
// Check both Vuex store and localStorage for Plex user
const plexUserId = computed(() => {
// First try Vuex store
const storeId = store.getters["user/plexUserId"];
if (storeId) return storeId;
// Fallback to localStorage
const userData = localStorage.getItem("plex_user_data");
if (userData) {
try {
return JSON.parse(userData).id;
} catch {
return null;
}
}
return null;
});
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 = [
{ {
type: GraphTypes.Plays, type: GraphTypes.Plays,
@@ -193,14 +129,6 @@
graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value) graphValueViewMode.find(viewMode => viewMode.type === graphViewMode.value)
); );
const {
fetchHomeStats,
fetchPlaysByDate,
fetchPlaysByDayOfWeek,
fetchPlaysByHourOfDay,
fetchTopContent
} = useTautulliStats();
function convertDateStringToDayMonth(date: string, short = true): string { function convertDateStringToDayMonth(date: string, short = true): 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;
@@ -210,30 +138,8 @@
return short ? `${month}.${day}` : `${day}.${month}.${year}`; return short ? `${month}.${day}` : `${day}.${month}.${year}`;
} }
async function fetchChartData() { function activityPerDay(dataPromise: Promise<any>) {
if (!plexUserId.value) return; dataPromise.then(dayData => {
try {
const yAxis =
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
// Fetch all data in parallel using efficient Tautulli APIs
const [homeStats, dayData, weekData, hourData, topContentData] =
await Promise.all([
fetchHomeStats(plexUserId.value, days.value, "duration"), // Need duration for hours
fetchPlaysByDate(days.value, yAxis, plexUserId.value),
fetchPlaysByDayOfWeek(days.value, yAxis, plexUserId.value),
fetchPlaysByHourOfDay(days.value, yAxis, plexUserId.value),
fetchTopContent(days.value, 10, plexUserId.value)
]);
// Set overall stats
watchStats.value = homeStats;
// Set top content
topContent.value = topContentData;
// Activity per day
playsByDayData.value = { playsByDayData.value = {
labels: dayData.map(d => labels: dayData.map(d =>
convertDateStringToDayMonth(d.date, dayData.length < 365) convertDateStringToDayMonth(d.date, dayData.length < 365)
@@ -248,8 +154,11 @@
} }
] ]
}; };
});
}
// Activity by day of week (stacked by media type) function playsByDayOfWeek(dataPromise: Promise<any>) {
dataPromise.then(weekData => {
playsByDayofweekData.value = { playsByDayofweekData.value = {
labels: weekData.labels, labels: weekData.labels,
series: [ series: [
@@ -258,22 +167,42 @@
{ name: "Music", data: weekData.music } { name: "Music", data: weekData.music }
] ]
}; };
});
}
// Hourly distribution function hourly(hourlyPromise: Promise<any>) {
hourlyPromise.then(hourData => {
hourlyData.value = { hourlyData.value = {
labels: hourData.labels, labels: hourData.labels,
series: [{ name: "Plays", data: hourData.data }] series: [{ name: "Plays", data: hourData.data }]
}; };
});
}
async function fetchChartData() {
try {
const yAxis =
graphViewMode.value === GraphTypes.Plays ? "plays" : "duration";
// Fetch all data in parallel using efficient Tautulli APIs
fetchHomeStats(days.value, "duration").then(
(homeStats: WatchStats) => (watchStats.value = homeStats)
);
// Activity per day (line graph of last n days)
activityPerDay(fetchPlaysByDate(days.value, yAxis));
// Activity by day of week (stacked by media type)
playsByDayOfWeek(fetchPlaysByDayOfWeek(days.value, yAxis));
// Hourly distribution
hourly(fetchPlaysByHourOfDay(days.value, yAxis));
} catch (error) { } catch (error) {
console.error("[ActivityPage] Error fetching chart data:", error); console.error("[ActivityPage] Error fetching chart data:", error);
} }
} }
onMounted(() => { onMounted(fetchChartData);
if (plexUsername.value) {
fetchChartData();
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -297,7 +226,7 @@
@include mobile-only { @include mobile-only {
font-size: 1.5rem; font-size: 1.5rem;
margin: 0 0 1rem 0; margin: 1rem 0;
} }
} }
@@ -310,36 +239,7 @@
gap: 1rem; gap: 1rem;
} }
} }
&__top-content {
margin-top: 2rem;
} }
}
// .filter {
// display: flex;
// flex-direction: row;
// flex-wrap: wrap;
// align-items: center;
// margin-bottom: 2rem;
// h2 {
// margin-bottom: 0.5rem;
// width: 100%;
// font-weight: 400;
// }
// &-item:not(:first-of-type) {
// margin-left: 1rem;
// }
// .dayinput {
// font-size: 1.2rem;
// max-width: 3rem;
// background-color: $background-ui;
// color: $text-color;
// }
// }
.chart-card { .chart-card {
background: var(--background-ui); background: var(--background-ui);
@@ -351,7 +251,7 @@
padding: 1rem; padding: 1rem;
} }
&__title { h3 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 500; font-weight: 500;
@@ -374,13 +274,6 @@
} }
} }
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.controls { .controls {
display: flex; display: flex;
gap: 2rem; gap: 2rem;
@@ -450,144 +343,4 @@
color: var(--text-color-60); color: var(--text-color-60);
user-select: none; user-select: none;
} }
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@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-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--text-color-50);
transition: all 0.2s;
&:hover {
border-color: var(--text-color);
transform: translateY(-2px);
}
}
.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 {
padding: 2rem;
text-align: center;
h1 {
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 1rem;
svg {
margin-right: 1rem;
height: 3rem;
width: 3rem;
}
}
p {
font-size: 1.2rem;
color: var(--text-color-60);
}
@include mobile {
padding: 1rem;
padding-right: 0;
h1 {
font-size: 1.65rem;
svg {
margin-right: 1rem;
height: 2rem;
width: 2rem;
}
}
}
}
</style> </style>