5 Commits

Author SHA1 Message Date
renovate[bot]
be665af0f0 Update dependency vite to v7 2026-02-15 00:35:04 +00:00
640feea592 Fix tooltip positioning 2026-02-14 17:27:15 -05:00
45dfa503aa Remove deal from tooltip 2026-02-14 17:22:34 -05:00
87f98fa8c0 Update colours and fix errors in console 2026-02-14 16:59:42 -05:00
e8dc79f981 Update themes and match system 2026-02-14 16:28:23 -05:00
5 changed files with 380 additions and 79 deletions

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
@@ -17,13 +17,7 @@
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
<!-- Load Material Symbols font with optimal display settings --> <!-- Load Material Symbols font with optimal display settings -->
<link
rel="preload"
as="font"
type="font/woff2"
href="https://fonts.gstatic.com/s/materialsymbolsoutlined/v211/gok-H7zzDkdnRel8-DQ6KAXJ69wP1tGnf4ZGhQcyWwg.woff2"
crossorigin
/>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block"
@@ -38,6 +32,20 @@
src="https://umami.davegallant.ca/script.js" src="https://umami.davegallant.ca/script.js"
data-website-id="59ffe8be-509a-471e-8cd6-a63c5b35b7aa" data-website-id="59ffe8be-509a-471e-8cd6-a63c5b35b7aa"
></script> ></script>
<!-- Theme detection script - runs before Vue loads to prevent flash of unstyled content -->
<script>
(function() {
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
// Apply theme to html element
document.documentElement.setAttribute('data-bs-theme', theme);
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head> </head>
<body> <body>
<noscript> <noscript>

View File

@@ -2,9 +2,7 @@
import axios from "axios"; import axios from "axios";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import Loading from "vue-loading-overlay"; import { ref } from "vue";
import { install } from "@github/hotkey";
import { ref, reactive } from "vue";
import "vue-loading-overlay/dist/css/index.css"; import "vue-loading-overlay/dist/css/index.css";
@@ -23,18 +21,78 @@ export default {
loadingTooltip: {}, loadingTooltip: {},
tooltipPosition: { x: 0, y: 0 }, tooltipPosition: { x: 0, y: 0 },
isMobile: false, isMobile: false,
currentTheme: 'dark',
mediaQueryListener: null,
vuetifyTheme: null,
darkModeQuery: null,
themeChangeHandler: null,
}; };
}, },
mounted() { mounted() {
window.addEventListener("keydown", this.handleKeyDown); window.addEventListener("keydown", this.handleKeyDown);
this.detectMobile(); this.detectMobile();
this.fetchDeals(); this.fetchDeals();
// Initialize theme on next tick to ensure Vuetify is ready
this.$nextTick(() => {
this.initializeTheme();
this.setupThemeListener();
});
}, },
beforeUnmount() { beforeUnmount() {
window.removeEventListener("keydown", this.handleKeyDown); window.removeEventListener("keydown", this.handleKeyDown);
window.removeEventListener("resize", this.detectMobile); window.removeEventListener("resize", this.detectMobile);
if (this.darkModeQuery && this.themeChangeHandler) {
this.darkModeQuery.removeEventListener('change', this.themeChangeHandler);
}
}, },
methods: { methods: {
initializeTheme() {
// If no saved preference, apply system preference now
const savedTheme = localStorage.getItem('vuetify-theme');
if (!savedTheme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = prefersDark ? 'dark' : 'light';
this.applyTheme(theme);
} else {
// Get current theme name from Vuetify
this.currentTheme = this.$vuetify.theme.global.name;
}
},
setupThemeListener() {
// Listen for system theme preference changes
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.mediaQueryListener = darkModeQuery;
// Use arrow function to preserve 'this' context
const themeChangeHandler = (e) => {
// Only auto-update theme if user hasn't set a preference manually
const savedTheme = localStorage.getItem('vuetify-theme');
if (!savedTheme) {
const newTheme = e.matches ? 'dark' : 'light';
console.log('System theme changed to:', newTheme);
this.applyTheme(newTheme);
}
};
darkModeQuery.addEventListener('change', themeChangeHandler);
// Store the handler so we can remove it later if needed
this.themeChangeHandler = themeChangeHandler;
this.darkModeQuery = darkModeQuery;
},
applyTheme(theme) {
// Apply theme using Vuetify's theme API
this.$vuetify.theme.global.name = theme;
this.currentTheme = theme;
localStorage.setItem('vuetify-theme', theme);
// Also update data-bs-theme for any custom CSS that uses it
document.documentElement.setAttribute('data-bs-theme', theme === 'dark' ? 'dark' : 'light');
},
toggleTheme() {
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
this.applyTheme(newTheme);
},
detectMobile() { detectMobile() {
// Detect if device is mobile/tablet based on touch capability and screen size // Detect if device is mobile/tablet based on touch capability and screen size
const hasTouch = () => { const hasTouch = () => {
@@ -46,11 +104,11 @@ export default {
false false
); );
}; };
const isMobileScreen = () => { const isMobileScreen = () => {
return window.innerWidth <= 1024; return window.innerWidth <= 1024;
}; };
this.isMobile = hasTouch() || isMobileScreen(); this.isMobile = hasTouch() || isMobileScreen();
window.addEventListener("resize", this.detectMobile); window.addEventListener("resize", this.detectMobile);
}, },
@@ -143,7 +201,7 @@ export default {
formatDate() { formatDate() {
return (v) => { return (v) => {
const date = dayjs(String(v)); const date = dayjs(String(v));
return date.format("hh:mm A (MM/DD)"); return date.format("YYYY-MM-DD hh:mm A");
}; };
}, },
filteredTopics() { filteredTopics() {
@@ -171,21 +229,45 @@ export default {
visibleHeaders() { visibleHeaders() {
const baseHeaders = [ const baseHeaders = [
{ title: "Deal", value: "title", align: "center" }, { title: "Deal", value: "title", align: "center" },
{ title: "Score", value: "score", align: "center", sortable: true }, { title: "Score", value: "score", align: "center" },
]; ];
// Only show Last Post column on desktop // Only show Last Post column on desktop
if (!this.isMobile) { if (!this.isMobile) {
baseHeaders.push({ baseHeaders.push({
title: "Last Post", title: "Last Post",
value: "last_post_time", value: "last_post_time",
align: "center", align: "center",
sortable: true,
}); });
} }
return baseHeaders; return baseHeaders;
}, },
tooltipStyle() {
if (this.hoveredTopicId === null || !this.tooltipData[this.hoveredTopicId]) {
return {};
}
let top = this.tooltipPosition.y + 10;
let left = this.tooltipPosition.x + 10;
const tooltipWidth = 420;
// Check if tooltip would go off right side of screen
if (left + tooltipWidth > window.innerWidth) {
// Position to the left of cursor instead
left = Math.max(10, this.tooltipPosition.x - tooltipWidth - 10);
}
// Keep tooltip within vertical bounds, allowing scrolling of content
top = Math.max(10, Math.min(top, window.innerHeight - 100));
return {
position: 'fixed',
left: Math.max(10, left) + 'px',
top: top + 'px',
zIndex: 9999,
};
},
}, },
}; };
</script> </script>
@@ -210,8 +292,7 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
<v-data-table <v-data-table
:headers="visibleHeaders" :headers="visibleHeaders"
:items="filteredTopics" :items="filteredTopics"
:sort-by="sortColumn" :sort-by="sortBy"
v-model:sortBy="sortBy"
:items-per-page="50" :items-per-page="50"
> >
<template #item.title="{ item }"> <template #item.title="{ item }">
@@ -252,18 +333,9 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
<div <div
v-if="hoveredTopicId !== null && tooltipData[hoveredTopicId]" v-if="hoveredTopicId !== null && tooltipData[hoveredTopicId]"
class="deal-tooltip" class="deal-tooltip"
:style="{ :style="tooltipStyle"
position: 'fixed',
left: tooltipPosition.x + 10 + 'px',
top: tooltipPosition.y + 10 + 'px',
zIndex: 9999,
}"
> >
<div class="tooltip-content"> <div class="tooltip-content">
<div class="tooltip-header">{{ tooltipData[hoveredTopicId].topic.title }}</div>
<div class="tooltip-dealer">
{{ tooltipData[hoveredTopicId].topic.Offer.dealer_name }}
</div>
<div class="tooltip-stats"> <div class="tooltip-stats">
<span class="stat-item"> <span class="stat-item">
<span class="material-symbols-outlined">visibility</span> <span class="material-symbols-outlined">visibility</span>
@@ -278,6 +350,9 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
<strong>Description:</strong> <strong>Description:</strong>
{{ tooltipData[hoveredTopicId].description }} {{ tooltipData[hoveredTopicId].description }}
</div> </div>
<div class="tooltip-dealer">
{{ tooltipData[hoveredTopicId].topic.Offer.dealer_name }}
</div>
<div v-if="tooltipData[hoveredTopicId].first_post" class="tooltip-first-post"> <div v-if="tooltipData[hoveredTopicId].first_post" class="tooltip-first-post">
<strong>First Post:</strong> <strong>First Post:</strong>
{{ tooltipData[hoveredTopicId].first_post }} {{ tooltipData[hoveredTopicId].first_post }}
@@ -313,20 +388,21 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
} }
.tooltip-content { .tooltip-content {
background: #24283b; background: var(--tooltip-bg);
border: 2px solid #a9b1d6; border: 2px solid var(--tooltip-border);
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 13px; font-size: 13px;
color: #e0e0e0; color: var(--text-primary);
text-align: left; text-align: left;
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
} }
.tooltip-header { .tooltip-header {
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: 14px;
color: #d0d0d0; color: var(--text-primary);
margin-bottom: 8px; margin-bottom: 8px;
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
@@ -334,7 +410,7 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
.tooltip-dealer { .tooltip-dealer {
font-size: 12px; font-size: 12px;
color: #c0c0c0; color: var(--text-secondary);
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -343,6 +419,7 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
gap: 12px; gap: 12px;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary);
} }
.stat-item { .stat-item {
@@ -358,34 +435,57 @@ const sortBy = ref([{ key: "score", order: "desc" }]);
.tooltip-description { .tooltip-description {
margin-bottom: 8px; margin-bottom: 8px;
padding: 8px; padding: 8px;
background: rgba(160, 160, 160, 0.1); background: var(--bg-secondary);
border-left: 2px solid #a9b1d6; border-left: 2px solid var(--tooltip-border);
border-radius: 2px; border-radius: 2px;
font-size: 12px; font-size: 12px;
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
max-height: 60px; max-height: 60px;
overflow-y: auto; overflow-y: auto;
color: var(--text-primary);
} }
.tooltip-first-post { .tooltip-first-post {
margin-bottom: 8px; margin-bottom: 8px;
padding: 8px; padding: 8px;
background: rgba(160, 160, 160, 0.1); background: var(--bg-secondary);
border-left: 2px solid #a9b1d6; border-left: 2px solid var(--tooltip-border);
border-radius: 2px; border-radius: 2px;
font-size: 12px; font-size: 12px;
white-space: normal; white-space: normal;
word-wrap: break-word; word-wrap: break-word;
max-height: 60px; max-height: 60px;
overflow-y: auto; overflow-y: auto;
color: var(--text-primary);
} }
.tooltip-times { .tooltip-times {
font-size: 11px; font-size: 11px;
color: #b0b0b0; color: var(--text-secondary);
border-top: 1px solid #555555; border-top: 1px solid var(--border-color);
padding-top: 8px; padding-top: 8px;
margin-top: 8px; margin-top: 8px;
} }
/* Filter input styling */
:deep(.v-text-field) {
--v-field-border-color: #cccccc;
}
html[data-bs-theme="light"] :deep(.v-text-field) {
--v-field-border-color: #e8e8e8;
}
html[data-bs-theme="light"] :deep(.v-field__input) {
background-color: #d0d0d0 !important;
}
html[data-bs-theme="light"] :deep(.v-field--focused .v-field__input) {
background-color: #e8e8e8 !important;
}
html[data-bs-theme="dark"] :deep(.v-text-field) {
--v-field-border-color: #555555;
}
</style> </style>

View File

@@ -6,7 +6,12 @@ import "./theme.css";
import { registerPlugins } from "@/plugins"; import { registerPlugins } from "@/plugins";
const routes = []; const routes = [
{
path: '/:pathMatch(.*)*',
component: App,
},
];
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),

View File

@@ -1,35 +1,68 @@
/** /**
* plugins/vuetify.js * plugins/vuetify.js
* *
* Framework documentation: https://vuetifyjs.com` * Framework documentation: https://vuetifyjs.com
*/ */
// Styles // Styles
import "@mdi/font/css/materialdesignicons.css"; import "@mdi/font/css/materialdesignicons.css";
import "vuetify/styles"; import "vuetify/styles";
const tokyoNight = {
dark: true,
colors: {
background: "#1a1b26",
surface: "#24283b",
primary: "#7aa2f7",
secondary: "#b4f9f8",
accent: "#ff9e64",
error: "#f7768e",
info: "#2ac3de",
success: "#9ece6a",
warning: "#e0af68",
},
};
// Composables // Composables
import { createVuetify } from "vuetify"; import { createVuetify } from "vuetify";
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides const lightTheme = {
export default createVuetify({ dark: false,
colors: {
background: "#ffffff",
surface: "#f5f5f5",
primary: "#1976d2",
secondary: "#424242",
accent: "#82b1ff",
error: "#f44336",
info: "#2196f3",
success: "#4caf50",
warning: "#ff9800",
},
};
const darkTheme = {
dark: true,
colors: {
background: "#1a1a1a",
surface: "#2a2a2a",
primary: "#5b9cf5",
secondary: "#a0a0a0",
accent: "#7aa2f7",
error: "#f87171",
info: "#60a5fa",
success: "#4ade80",
warning: "#facc15",
},
};
function getDefaultTheme() {
// Check for saved theme preference
const savedTheme = localStorage.getItem('vuetify-theme');
if (savedTheme) {
return savedTheme;
}
// Check system preference
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
// Create vuetify instance
const vuetify = createVuetify({
theme: { theme: {
defaultTheme: "tokyoNight", defaultTheme: getDefaultTheme(),
themes: { tokyoNight }, themes: {
light: lightTheme,
dark: darkTheme,
},
}, },
}); });
// Export the vuetify instance so other parts of the app can access it
export { vuetify as default };

View File

@@ -1,12 +1,3 @@
/* Material Symbols font optimization - prevent flash of unstyled text */
@font-face {
font-family: 'Material Symbols Outlined';
src: url('https://fonts.gstatic.com/s/materialsymbolsoutlined/v211/gok-H7zzDkdnRel8-DQ6KAXJ69wP1tGnf4ZGhQcyWwg.woff2') format('woff2');
font-weight: 100 700;
font-style: normal;
font-display: block;
}
.material-symbols-outlined { .material-symbols-outlined {
font-family: 'Material Symbols Outlined'; font-family: 'Material Symbols Outlined';
font-weight: normal; font-weight: normal;
@@ -21,8 +12,77 @@
direction: ltr; direction: ltr;
} }
/* Theme-aware CSS variables */
:root {
/* Light theme (default) */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #212529;
--text-secondary: #6c757d;
--border-color: #dee2e6;
--tooltip-bg: #f8f9fa;
--tooltip-border: #dee2e6;
--tooltip-text: #212529;
--link-color: #0d6efd;
--link-visited: #990000;
--footer-bg: #f8f9fa;
--footer-text: #212529;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #2a2a2a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #3a3a3a;
--tooltip-bg: #2a2a2a;
--tooltip-border: #444444;
--tooltip-text: #e0e0e0;
--link-color: #5b9cf5;
--link-visited: #990000;
--footer-bg: #1a1a1a;
--footer-text: #e0e0e0;
}
}
/* Support for explicit data-bs-theme attribute (Bootstrap override) */
html[data-bs-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2a2a2a;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #3a3a3a;
--tooltip-bg: #2a2a2a;
--tooltip-border: #444444;
--tooltip-text: #e0e0e0;
--link-color: #e8e8e8;
--link-visited: #990000;
--footer-bg: #1a1a1a;
--footer-text: #e0e0e0;
}
html[data-bs-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--text-primary: #212529;
--text-secondary: #6c757d;
--border-color: #dee2e6;
--tooltip-bg: #f8f9fa;
--tooltip-border: #dee2e6;
--tooltip-text: #212529;
--link-color: #333333;
--link-visited: #990000;
--footer-bg: #f8f9fa;
--footer-text: #212529;
}
body { body {
max-width: 100%; max-width: 100%;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
} }
html { html {
@@ -33,11 +93,12 @@ html {
} }
footer { footer {
background: #212529; background: var(--footer-bg);
color: white; color: var(--footer-text);
padding: 3px; padding: 3px;
padding-right: 10px; padding-right: 10px;
padding-left: 10px; padding-left: 10px;
border-top: 1px solid var(--border-color);
} }
.footer-left { .footer-left {
@@ -49,6 +110,14 @@ footer {
} }
.green-score { .green-score {
color: rgb(34, 139, 34) !important;
}
html[data-bs-theme="light"] .green-score {
color: rgb(34, 139, 34) !important;
}
html[data-bs-theme="dark"] .green-score {
color: rgb(158, 206, 106) !important; color: rgb(158, 206, 106) !important;
} }
@@ -57,15 +126,14 @@ footer {
} }
a { a {
color: var(--v-theme-primary); color: var(--link-color);
transition: color 0.2s ease;
} }
a:hover {
color: #d65d03;
}
a:visited { a:visited {
color: #53514f; color: var(--link-visited);
} }
@media (min-width: 769px) { @media (min-width: 769px) {
@@ -75,3 +143,90 @@ a:visited {
font-size: 1.2rem; font-size: 1.2rem;
} }
} }
/* Tooltip theme support */
.deal-tooltip {
pointer-events: none;
max-width: 400px;
}
.tooltip-content {
background: var(--tooltip-bg);
border: 2px solid var(--tooltip-border);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 13px;
color: var(--tooltip-text);
text-align: left;
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
.tooltip-header {
font-weight: bold;
font-size: 14px;
color: var(--text-primary);
margin-bottom: 8px;
white-space: normal;
word-wrap: break-word;
}
.tooltip-dealer {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.tooltip-stats {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stat-item .material-symbols-outlined {
font-size: 16px;
}
.tooltip-description {
margin-bottom: 8px;
padding: 8px;
background: var(--bg-secondary);
border-left: 2px solid var(--tooltip-border);
border-radius: 2px;
font-size: 12px;
white-space: normal;
word-wrap: break-word;
max-height: 60px;
overflow-y: auto;
color: var(--text-primary);
}
.tooltip-first-post {
margin-bottom: 8px;
padding: 8px;
background: var(--bg-secondary);
border-left: 2px solid var(--tooltip-border);
border-radius: 2px;
font-size: 12px;
white-space: normal;
word-wrap: break-word;
max-height: 60px;
overflow-y: auto;
color: var(--text-primary);
}
.tooltip-times {
font-size: 11px;
color: var(--text-secondary);
border-top: 1px solid var(--border-color);
padding-top: 8px;
margin-top: 8px;
}