From 20f294b8d7586186fb4c80f95d88be9835502e6a Mon Sep 17 00:00:00 2001 From: Dave Gallant Date: Sun, 15 Feb 2026 20:27:12 -0500 Subject: [PATCH] Cleanup frontend code --- .eslintignore | 2 + src/App.vue | 415 +++++++++++++++++++++----------------------------- src/main.js | 2 - src/theme.css | 280 +++++++++++++++++----------------- 4 files changed, 310 insertions(+), 389 deletions(-) create mode 100644 .eslintignore diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..763301f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 92c02b0..b441d95 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,157 +3,178 @@ import axios from "axios"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import "vue-loading-overlay/dist/css/index.css"; import "./theme.css"; -// Configure day.js with UTC support dayjs.extend(utc); export default { data() { return { - ascending: this.ascending, filter: decodeURIComponent(window.location.href.split("filter=")[1] || ""), - sortColumn: this.sortColumn, - sortMethod: 'score', + sortMethod: "score", topics: [], isMobile: false, - currentTheme: typeof localStorage !== 'undefined' ? (localStorage.getItem('theme') || 'auto') : 'auto', - mediaQueryListener: null, - vuetifyTheme: null, + currentTheme: "auto", darkModeQuery: null, themeChangeHandler: null, }; }, + mounted() { window.addEventListener("keydown", this.handleKeyDown); + window.addEventListener("resize", this.handleResize); this.detectMobile(); this.fetchDeals(); - // Initialize sort method from local storage this.initializeSortMethod(); - // Initialize theme immediately to prevent flash this.initializeTheme(); this.setupThemeListener(); }, + beforeUnmount() { window.removeEventListener("keydown", this.handleKeyDown); - window.removeEventListener("resize", this.detectMobile); + window.removeEventListener("resize", this.handleResize); if (this.darkModeQuery && this.themeChangeHandler) { - this.darkModeQuery.removeEventListener('change', this.themeChangeHandler); + this.darkModeQuery.removeEventListener("change", this.themeChangeHandler); } }, - methods: { - initializeTheme() { - // If no saved preference, default to auto - const savedTheme = localStorage.getItem('theme'); - if (!savedTheme) { - this.currentTheme = 'auto'; - this.applyTheme('auto', true); // skipSave=true to avoid redundant write - } else { - this.currentTheme = savedTheme; - // Apply saved theme (skipSave=true since it's already saved) - this.applyTheme(savedTheme, true); - } + + computed: { + filteredTopics() { + const filterTerm = this.filter.toLowerCase(); + + const filtered = this.topics.filter((row) => { + const searchText = `${row.title} [${row.Offer.dealer_name}]`.toLowerCase(); + return searchText.includes(filterTerm); + }); + + const sortFns = { + score: (a, b) => b.score - a.score, + views: (a, b) => b.total_views - a.total_views, + recency: (a, b) => new Date(b.last_post_time) - new Date(a.last_post_time), + }; + + return filtered.sort(sortFns[this.sortMethod] || sortFns.score); }, + + themeIcon() { + const icons = { auto: "brightness_auto", dark: "light_mode", light: "dark_mode" }; + return icons[this.currentTheme]; + }, + + themeTitle() { + const titles = { + auto: "Theme: Auto (click for Light)", + light: "Theme: Light (click for Dark)", + dark: "Theme: Dark (click for Auto)", + }; + return titles[this.currentTheme]; + }, + + sortIcon() { + const icons = { score: "trending_up", views: "visibility", recency: "schedule" }; + return icons[this.sortMethod]; + }, + + sortTitle() { + const titles = { + score: "Sort by Score (click for Views)", + views: "Sort by Views (click for Recency)", + recency: "Sort by Recency (click for Score)", + }; + return titles[this.sortMethod]; + }, + }, + + methods: { + formatDate(dateString) { + return dayjs(String(dateString)).format("YYYY-MM-DD hh:mm A"); + }, + + highlightText(text) { + if (!this.filter) return text; + + const lowerText = text.toLowerCase(); + const lowerFilter = this.filter.toLowerCase(); + + if (!lowerText.includes(lowerFilter)) return text; + + const regex = new RegExp(this.filter, "ig"); + return text.replace(regex, (match) => `${match}`); + }, + + initializeTheme() { + const savedTheme = localStorage.getItem("theme") || "auto"; + this.currentTheme = savedTheme; + this.applyTheme(savedTheme, true); + }, + setupThemeListener() { - // Listen for system theme preference changes - const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + this.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 set to 'auto' - const savedTheme = localStorage.getItem('theme'); - if (savedTheme === 'auto' || !savedTheme) { - const newTheme = e.matches ? 'dark' : 'light'; - console.log('System theme changed to:', newTheme); - this.applyThemeActual(newTheme); + this.themeChangeHandler = (e) => { + const savedTheme = localStorage.getItem("theme"); + if (savedTheme === "auto" || !savedTheme) { + this.applyThemeActual(e.matches ? "dark" : "light"); } }; - darkModeQuery.addEventListener('change', themeChangeHandler); - // Store the handler so we can remove it later if needed - this.themeChangeHandler = themeChangeHandler; - this.darkModeQuery = darkModeQuery; + this.darkModeQuery.addEventListener("change", this.themeChangeHandler); }, + applyTheme(theme, skipSave = false) { this.currentTheme = theme; + if (!skipSave) { - localStorage.setItem('theme', theme); + localStorage.setItem("theme", theme); } - // Determine actual theme to apply let actualTheme = theme; - if (theme === 'auto') { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - actualTheme = prefersDark ? 'dark' : 'light'; + if (theme === "auto") { + actualTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } this.applyThemeActual(actualTheme); }, - applyThemeActual(actualTheme) { - // Update data-bs-theme attribute for CSS variables to work - document.documentElement.setAttribute('data-bs-theme', actualTheme === 'dark' ? 'dark' : 'light'); - // Update HTML class for theme-based CSS selectors - if (actualTheme === 'dark') { - document.documentElement.classList.add('dark-theme'); - document.documentElement.classList.remove('light-theme'); - } else { - document.documentElement.classList.add('light-theme'); - document.documentElement.classList.remove('dark-theme'); - } + applyThemeActual(theme) { + document.documentElement.setAttribute("data-bs-theme", theme); + document.documentElement.classList.toggle("dark-theme", theme === "dark"); + document.documentElement.classList.toggle("light-theme", theme === "light"); }, + toggleTheme() { - // Cycle through: auto -> light -> dark -> auto - let newTheme; - if (this.currentTheme === 'auto') { - newTheme = 'light'; - } else if (this.currentTheme === 'light') { - newTheme = 'dark'; - } else { - newTheme = 'auto'; - } - this.applyTheme(newTheme); + const cycle = { auto: "light", light: "dark", dark: "auto" }; + this.applyTheme(cycle[this.currentTheme]); }, + detectMobile() { - // Detect if device is mobile/tablet based on touch capability and screen size - const hasTouch = () => { - return ( - (typeof window !== "undefined" && - ("ontouchstart" in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0)) || - false - ); - }; + const hasTouch = + "ontouchstart" in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0; - const isMobileScreen = () => { - return window.innerWidth <= 1024; - }; - - this.isMobile = hasTouch() || isMobileScreen(); - window.addEventListener("resize", this.detectMobile); + const isMobileScreen = window.innerWidth <= 1024; + this.isMobile = hasTouch || isMobileScreen; }, + + handleResize() { + this.detectMobile(); + }, + handleKeyDown(event) { - const isInput = ["INPUT", "TEXTAREA"].includes( - document.activeElement.tagName - ); + const isInput = ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName); + if (event.key === "/" && !isInput) { - event.preventDefault(); // prevent typing `/` into whatever is focused + event.preventDefault(); this.$refs.filter.focus(); } }, createFilterRoute(params) { this.$refs.filter.blur(); - history.pushState( - {}, - null, - `${window.location.origin}#/filter=${encodeURIComponent(params)}` - ); + history.pushState({}, null, `${window.location.origin}#/filter=${encodeURIComponent(params)}`); }, + fetchDeals() { axios .get("api/v1/topics") @@ -161,111 +182,21 @@ export default { this.topics = response.data; }) .catch((err) => { - console.log(err.response); + console.error("Failed to fetch deals:", err.response || err); }); }, - }, - computed: { - formatDate() { - return (v) => { - const date = dayjs(String(v)); - return date.format("YYYY-MM-DD hh:mm A"); - }; - }, - filteredTopics() { - let filtered = this.topics - .filter((row) => { - const titles = ( - row.title.toString() + - " [" + - row.Offer.dealer_name + - "]" - ).toLowerCase(); - const filterTerm = this.filter.toLowerCase(); - return titles.includes(filterTerm); - }); - // Sort based on selected method - if (this.sortMethod === 'score') { - return filtered.sort((a, b) => b.score - a.score); - } else if (this.sortMethod === 'views') { - return filtered.sort((a, b) => b.total_views - a.total_views); - } else { - return filtered.sort((a, b) => new Date(b.last_post_time) - new Date(a.last_post_time)); - } - }, - highlightMatches() { - return (v) => { - if (this.filter == "") return v; - const matchExists = v.toLowerCase().includes(this.filter.toLowerCase()); - if (!matchExists) return v; - - const re = new RegExp(this.filter, "ig"); - return v.replace(re, (matchedText) => `${matchedText}`); - }; - }, - highlightDealerName() { - return (dealerName) => { - if (this.filter == "") return dealerName; - const matchExists = dealerName.toLowerCase().includes(this.filter.toLowerCase()); - if (!matchExists) return dealerName; - - const re = new RegExp(this.filter, "ig"); - return dealerName.replace(re, (matchedText) => `${matchedText}`); - }; - }, - getThemeIcon() { - if (this.currentTheme === 'auto') { - return 'brightness_auto'; - } else if (this.currentTheme === 'dark') { - return 'light_mode'; - } else { - return 'dark_mode'; - } - }, - getThemeTitle() { - if (this.currentTheme === 'auto') { - return 'Theme: Auto (click for Light)'; - } else if (this.currentTheme === 'light') { - return 'Theme: Light (click for Dark)'; - } else { - return 'Theme: Dark (click for Auto)'; - } - }, initializeSortMethod() { - const saved = localStorage.getItem('sortMethod'); + const saved = localStorage.getItem("sortMethod"); if (saved) { this.sortMethod = saved; } }, + toggleSort() { - // Cycle through: score -> views -> recency -> score - if (this.sortMethod === 'score') { - this.sortMethod = 'views'; - } else if (this.sortMethod === 'views') { - this.sortMethod = 'recency'; - } else { - this.sortMethod = 'score'; - } - localStorage.setItem('sortMethod', this.sortMethod); - }, - getSortIcon() { - if (this.sortMethod === 'score') { - return 'trending_up'; - } else if (this.sortMethod === 'views') { - return 'visibility'; - } else { - return 'schedule'; - } - }, - getSortTitle() { - if (this.sortMethod === 'score') { - return 'Sort by Score (click for Views)'; - } else if (this.sortMethod === 'views') { - return 'Sort by Views (click for Recency)'; - } else { - return 'Sort by Recency (click for Score)'; - } + const cycle = { score: "views", views: "recency", recency: "score" }; + this.sortMethod = cycle[this.sortMethod]; + localStorage.setItem("sortMethod", this.sortMethod); }, }, }; @@ -273,82 +204,80 @@ export default { + + \ No newline at end of file diff --git a/src/main.js b/src/main.js index ddd42dd..86997a3 100644 --- a/src/main.js +++ b/src/main.js @@ -2,8 +2,6 @@ import { createApp } from "vue"; import App from "./App.vue"; import { createRouter, createWebHashHistory } from "vue-router"; -import "./theme.css"; - const routes = [ { path: '/:pathMatch(.*)*', diff --git a/src/theme.css b/src/theme.css index 577dab7..32fb2fd 100644 --- a/src/theme.css +++ b/src/theme.css @@ -1,3 +1,4 @@ +/* Material Symbols Icon Font */ .material-symbols-outlined { font-family: 'Material Symbols Outlined'; font-weight: normal; @@ -12,51 +13,95 @@ direction: ltr; } -/* Theme-aware CSS variables */ +/* ============================================ + Theme Variables + ============================================ */ + :root { /* Light theme (default) */ --bg-primary: #dddddd; --bg-secondary: #e8e8e8; + --bg-input: #f5f5f5; + --bg-input-focus: #ffffff; --text-primary: #212529; --text-secondary: #6c757d; --border-color: #d0d0d0; + --border-color-light: #cccccc; + --border-color-hover: #999999; --link-color: #212529; + --score-positive-bg: rgb(34, 139, 34); + --score-positive-text: white; + --score-negative-bg: rgb(247, 118, 142); + --score-negative-text: white; + --mark-bg: rgba(255, 193, 7, 0.3); + --shadow-light: rgba(0, 0, 0, 0.05); + --shadow-medium: rgba(0, 0, 0, 0.1); } -/* Dark theme */ +/* Dark theme via media query */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-bs-theme="light"]):not(.light-theme) { --bg-primary: #1a1a1a; --bg-secondary: #2a2a2a; + --bg-input: #1a1a1a; + --bg-input-focus: #2a2a2a; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --border-color: #3a3a3a; - --link-color: #212529; + --border-color-light: #555555; + --border-color-hover: #777777; + --link-color: #e0e0e0; + --score-positive-bg: rgb(158, 206, 106); + --score-positive-text: #1a1a1a; + --mark-bg: rgba(255, 193, 7, 0.4); + --shadow-light: rgba(255, 255, 255, 0.1); } } -/* Support for explicit data-bs-theme attribute (Bootstrap override) */ -html[data-bs-theme="dark"] { +/* Explicit dark theme */ +html[data-bs-theme="dark"], +html.dark-theme { --bg-primary: #1a1a1a; --bg-secondary: #2a2a2a; + --bg-input: #1a1a1a; + --bg-input-focus: #2a2a2a; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --border-color: #3a3a3a; + --border-color-light: #555555; + --border-color-hover: #777777; --link-color: #e0e0e0; + --score-positive-bg: rgb(158, 206, 106); + --score-positive-text: #1a1a1a; + --mark-bg: rgba(255, 193, 7, 0.4); + --shadow-light: rgba(255, 255, 255, 0.1); } +/* Explicit light theme */ html[data-bs-theme="light"], html.light-theme { --bg-primary: #dddddd; --bg-secondary: #e8e8e8; + --bg-input: #f5f5f5; + --bg-input-focus: #ffffff; --text-primary: #212529; --text-secondary: #6c757d; --border-color: #d0d0d0; + --border-color-light: #cccccc; + --border-color-hover: #999999; --link-color: #212529; + --score-positive-bg: rgb(34, 139, 34); + --score-positive-text: white; + --mark-bg: rgba(255, 193, 7, 0.3); + --shadow-light: rgba(0, 0, 0, 0.05); } +/* ============================================ + Base Styles + ============================================ */ + html { - font-family: sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; min-width: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; @@ -73,22 +118,6 @@ body { padding: 0; } -.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; -} - -.red-score { - color: rgb(247, 118, 142) !important; -} - a { color: var(--link-color); transition: color 0.2s ease; @@ -98,7 +127,9 @@ a:visited { color: var(--link-color); } -/* App styles */ +/* ============================================ + Layout + ============================================ */ .container { max-width: 1200px; @@ -119,14 +150,18 @@ a:visited { align-items: center; } +/* ============================================ + Search Input + ============================================ */ + .search-input { flex: 1; max-width: 500px; padding: 10px 12px; font-size: 14px; - border: 1px solid #cccccc; + border: 1px solid var(--border-color-light); border-radius: 4px; - background-color: #f5f5f5; + background-color: var(--bg-input); color: var(--text-primary); transition: all 0.2s ease; font-family: inherit; @@ -134,36 +169,28 @@ a:visited { .search-input:focus { outline: none; - border-color: #999999; - background-color: #ffffff; - box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05); -} - -html.dark-theme .search-input { - border-color: #555555; - background-color: #1a1a1a; - color: #e0e0e0; -} - -html.dark-theme .search-input:focus { - background-color: #2a2a2a; - border-color: #777777; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1); + border-color: var(--border-color-hover); + background-color: var(--bg-input-focus); + box-shadow: 0 0 0 2px var(--shadow-light); } .search-input::placeholder { color: var(--text-secondary); } -.sort-toggle { +/* ============================================ + Icon Buttons (Theme & Sort Toggle) + ============================================ */ + +.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; - border: 1px solid #cccccc; + border: 1px solid var(--border-color-light); border-radius: 4px; - background-color: #f5f5f5; + background-color: var(--bg-input); color: var(--text-primary); cursor: pointer; transition: all 0.2s ease; @@ -172,35 +199,26 @@ html.dark-theme .search-input:focus { flex-shrink: 0; } -.sort-toggle:hover { - background-color: #e8e8e8; - border-color: #999999; +.icon-button:hover { + background-color: var(--bg-secondary); + border-color: var(--border-color-hover); } -.sort-toggle:active { +.icon-button:active { transform: scale(0.95); } -html.dark-theme .sort-toggle { - border-color: #555555; - background-color: #1a1a1a; - color: #e0e0e0; -} - -html.dark-theme .sort-toggle:hover { - background-color: #2a2a2a; - border-color: #777777; -} - +/* Legacy class names for compatibility */ +.sort-toggle, .theme-toggle { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; - border: 1px solid #cccccc; + border: 1px solid var(--border-color-light); border-radius: 4px; - background-color: #f5f5f5; + background-color: var(--bg-input); color: var(--text-primary); cursor: pointer; transition: all 0.2s ease; @@ -209,25 +227,20 @@ html.dark-theme .sort-toggle:hover { flex-shrink: 0; } +.sort-toggle:hover, .theme-toggle:hover { - background-color: #e8e8e8; - border-color: #999999; + background-color: var(--bg-secondary); + border-color: var(--border-color-hover); } +.sort-toggle:active, .theme-toggle:active { transform: scale(0.95); } -html.dark-theme .theme-toggle { - border-color: #555555; - background-color: #1a1a1a; - color: #e0e0e0; -} - -html.dark-theme .theme-toggle:hover { - background-color: #2a2a2a; - border-color: #777777; -} +/* ============================================ + Cards Grid + ============================================ */ .cards-grid { display: grid; @@ -248,11 +261,14 @@ html.dark-theme .theme-toggle:hover { } .deal-card:hover { - background-color: #15151515; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - border-color: #999999; + box-shadow: 0 4px 8px var(--shadow-medium); + border-color: var(--border-color-hover); } +/* ============================================ + Card Header + ============================================ */ + .card-header { display: flex; gap: 12px; @@ -286,26 +302,6 @@ html.dark-theme .theme-toggle:hover { text-decoration: underline; } -.card-meta { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 13px; - margin-bottom: 12px; -} - -.dealer-name { - color: var(--text-secondary); - font-weight: 500; - font-size: 13px; -} - -.card-timestamp { - color: var(--text-secondary); - font-size: 12px; - margin-top: 8px; -} - .card-link { display: inline-flex; align-items: center; @@ -324,6 +320,10 @@ html.dark-theme .theme-toggle:hover { font-size: 18px; } +/* ============================================ + Score Bubble + ============================================ */ + .score-bubble { display: inline-flex; align-items: center; @@ -341,26 +341,14 @@ html.dark-theme .theme-toggle:hover { } .score-bubble.positive { - background-color: rgb(34, 139, 34); - color: white; + background-color: var(--score-positive-bg); + color: var(--score-positive-text); box-shadow: 0 1px 3px rgba(34, 139, 34, 0.2); } -html.light-theme .score-bubble.positive { - background-color: rgb(34, 139, 34); - color: white; - box-shadow: 0 1px 3px rgba(34, 139, 34, 0.2); -} - -html.dark-theme .score-bubble.positive { - background-color: rgb(158, 206, 106); - color: #1a1a1a; - box-shadow: 0 1px 3px rgba(158, 206, 106, 0.2); -} - .score-bubble.negative { - background-color: rgb(247, 118, 142); - color: white; + background-color: var(--score-negative-bg); + color: var(--score-negative-text); box-shadow: 0 1px 3px rgba(247, 118, 142, 0.2); } @@ -370,6 +358,24 @@ html.dark-theme .score-bubble.positive { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); } +/* ============================================ + Card Meta & Details + ============================================ */ + +.card-meta { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + margin-bottom: 12px; +} + +.dealer-name { + color: var(--text-secondary); + font-weight: 500; + font-size: 13px; +} + .card-details { margin-top: 12px; padding-top: 12px; @@ -398,26 +404,27 @@ html.dark-theme .score-bubble.positive { font-weight: 500; } -.details-section { - margin-bottom: 12px; -} - -.details-section strong { - display: block; - color: var(--text-primary); - margin-bottom: 4px; - font-size: 13px; -} - -.details-section p { - margin: 0; +.card-timestamp { color: var(--text-secondary); font-size: 12px; - line-height: 1.4; - word-wrap: break-word; + margin-top: 8px; } -/* Mobile responsive */ +/* ============================================ + Mark Highlighting + ============================================ */ + +mark { + background-color: var(--mark-bg); + color: inherit; + font-weight: 600; + border-radius: 2px; +} + +/* ============================================ + Mobile Responsive + ============================================ */ + @media (max-width: 768px) { .cards-grid { grid-template-columns: 1fr; @@ -430,19 +437,4 @@ html.dark-theme .score-bubble.positive { .search-input { max-width: 100%; } -} - -/* Mark highlighting */ -mark { - background-color: rgba(255, 193, 7, 0.3); - color: inherit; - font-weight: 600; - border-radius: 2px; -} - -html.dark-theme mark { - background-color: rgba(255, 193, 7, 0.4); - color: inherit; - font-weight: 600; - border-radius: 2px; -} +} \ No newline at end of file