Add tooltip when hovering over deal

This commit is contained in:
2026-02-14 09:15:36 -05:00
parent 1e69b2b57e
commit a091f0ef0e
3 changed files with 276 additions and 4 deletions

View File

@@ -63,6 +63,7 @@ func (a *App) Run(httpPort string) {
func (a *App) initializeRoutes() { func (a *App) initializeRoutes() {
a.Router.HandleFunc("/topics", a.listTopics).Methods("GET") a.Router.HandleFunc("/topics", a.listTopics).Methods("GET")
a.Router.HandleFunc("/topics/{id}", a.getTopicDetails).Methods("GET")
} }
// func respondWithError(w http.ResponseWriter, code int, message string) { // func respondWithError(w http.ResponseWriter, code int, message string) {
@@ -87,6 +88,90 @@ func (a *App) listTopics(w http.ResponseWriter, r *http.Request) {
respondWithJSON(w, http.StatusOK, a.CurrentTopics) respondWithJSON(w, http.StatusOK, a.CurrentTopics)
} }
// getTopicDetails godoc
// @Summary Get detailed information about a specific topic
// @Description Fetches full details including recent comments for a topic by ID
// @ID get-topic-details
// @Param id path int true "Topic ID"
// @Router /topics/{id} [get]
// @Success 200 {object} TopicDetails
func (a *App) getTopicDetails(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
topicID := vars["id"]
// Find topic in current topics
var topic *Topic
for i := range a.CurrentTopics {
if fmt.Sprintf("%d", a.CurrentTopics[i].TopicID) == topicID {
topic = &a.CurrentTopics[i]
break
}
}
if topic == nil {
respondWithJSON(w, http.StatusNotFound, map[string]string{"error": "Topic not found"})
return
}
// Fetch detailed info from RFD API
requestURL := fmt.Sprintf("https://forums.redflagdeals.com/api/topics/%s", topicID)
res, err := http.Get(requestURL)
if err != nil {
log.Warn().Msgf("error fetching topic details: %s\n", err)
respondWithJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to fetch details"})
return
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
log.Warn().Msgf("could not read response body: %s\n", err)
respondWithJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to read response"})
return
}
var rfdResponse map[string]interface{}
err = json.Unmarshal([]byte(body), &rfdResponse)
if err != nil {
log.Warn().Msgf("could not unmarshal response body: %s", err)
respondWithJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to parse response"})
return
}
// Extract relevant fields for tooltip
details := TopicDetails{
Topic: *topic,
Description: extractDescription(rfdResponse),
FirstPost: extractFirstPost(rfdResponse),
}
respondWithJSON(w, http.StatusOK, details)
}
func extractDescription(data map[string]interface{}) string {
if topic, ok := data["topic"].(map[string]interface{}); ok {
if description, ok := topic["description"].(string); ok {
return description
}
}
return ""
}
func extractFirstPost(data map[string]interface{}) string {
if posts, ok := data["posts"].([]interface{}); ok && len(posts) > 0 {
if firstPost, ok := posts[0].(map[string]interface{}); ok {
if body, ok := firstPost["body"].(string); ok {
// Truncate to first 200 characters
if len(body) > 200 {
return body[:200] + "..."
}
return body
}
}
}
return ""
}
func (a *App) refreshTopics() { func (a *App) refreshTopics() {
for { for {
log.Info().Msg("Refreshing topics") log.Info().Msg("Refreshing topics")

View File

@@ -27,3 +27,9 @@ type Offer struct {
DealerName string `json:"dealer_name"` DealerName string `json:"dealer_name"`
Url string `json:"url"` Url string `json:"url"`
} // @name Offer } // @name Offer
type TopicDetails struct {
Topic Topic `json:"topic"`
Description string `json:"description"`
FirstPost string `json:"first_post"`
} // @name TopicDetails

View File

@@ -1,11 +1,15 @@
<script> <script>
import axios from "axios"; import axios from "axios";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import Loading from "vue-loading-overlay"; import Loading from "vue-loading-overlay";
import { install } from "@github/hotkey"; 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";
import { ref } from "vue";
// Configure day.js with UTC support
dayjs.extend(utc);
export default { export default {
data() { data() {
@@ -14,6 +18,10 @@ export default {
filter: window.location.href.split("filter=")[1] || "", filter: window.location.href.split("filter=")[1] || "",
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
topics: [], topics: [],
hoveredTopicId: null,
tooltipData: {},
loadingTooltip: {},
tooltipPosition: { x: 0, y: 0 },
}; };
}, },
mounted() { mounted() {
@@ -33,6 +41,53 @@ export default {
this.$refs.filter.focus(); this.$refs.filter.focus();
} }
}, },
handleTitleHover(topic, event) {
this.hoveredTopicId = topic.topic_id;
this.tooltipPosition = {
x: event.clientX,
y: event.clientY,
};
this.loadTopicDetails(topic.topic_id);
},
handleTitleLeave() {
this.hoveredTopicId = null;
},
handleMouseMove(event) {
if (this.hoveredTopicId !== null) {
this.tooltipPosition = {
x: event.clientX,
y: event.clientY,
};
}
},
loadTopicDetails(topicId) {
if (!topicId) {
console.warn("Topic ID is undefined");
return;
}
if (this.tooltipData[topicId]) {
return; // Already loaded
}
if (this.loadingTooltip[topicId]) {
return; // Already loading
}
this.loadingTooltip[topicId] = true;
axios
.get(`api/v1/topics/${topicId}`)
.then((response) => {
this.tooltipData[topicId] = response.data;
})
.catch((err) => {
console.log("Error loading topic details:", err);
})
.finally(() => {
this.loadingTooltip[topicId] = false;
});
},
createFilterRoute(params) { createFilterRoute(params) {
this.$refs.filter.blur(); this.$refs.filter.blur();
history.pushState( history.pushState(
@@ -55,7 +110,8 @@ export default {
computed: { computed: {
formatDate() { formatDate() {
return (v) => { return (v) => {
return dayjs(String(v)).format("hh:mm A z (MM/DD)"); const date = dayjs(String(v));
return date.format("hh:mm A (MM/DD)");
}; };
}, },
filteredTopics() { filteredTopics() {
@@ -88,7 +144,6 @@ export default {
const headers = [ const headers = [
{ 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", sortable: true },
{ title: "Views", value: "total_views", align: "center", sortable: true },
{ {
title: "Last Post", title: "Last Post",
value: "last_post_time", value: "last_post_time",
@@ -123,6 +178,9 @@ const sortBy = ref([{ key: "score", order: "desc" }]); // Vuetify 3 format
<a <a
:href="`https://forums.redflagdeals.com${item.web_path}`" :href="`https://forums.redflagdeals.com${item.web_path}`"
target="_blank" target="_blank"
@mouseenter="handleTitleHover(item, $event)"
@mouseleave="handleTitleLeave"
@mousemove="handleMouseMove"
v-html=" v-html="
highlightMatches( highlightMatches(
item.title + ' [' + item.Offer.dealer_name + '] ' item.title + ' [' + item.Offer.dealer_name + '] '
@@ -153,12 +211,53 @@ const sortBy = ref([{ key: "score", order: "desc" }]); // Vuetify 3 format
<v-progress-linear indeterminate color="grey" /> <v-progress-linear indeterminate color="grey" />
</template> </template>
</v-data-table> </v-data-table>
<!-- Tooltip for deal details -->
<div
v-if="hoveredTopicId !== null && tooltipData[hoveredTopicId]"
class="deal-tooltip"
:style="{
position: 'fixed',
left: tooltipPosition.x + 10 + 'px',
top: tooltipPosition.y + 10 + 'px',
zIndex: 9999,
}"
>
<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">
<span class="stat-item">
<span class="material-symbols-outlined">visibility</span>
{{ tooltipData[hoveredTopicId].topic.total_views }} views
</span>
<span class="stat-item">
<span class="material-symbols-outlined">chat</span>
{{ tooltipData[hoveredTopicId].topic.total_replies }} replies
</span>
</div>
<div v-if="tooltipData[hoveredTopicId].description" class="tooltip-description">
<strong>Description:</strong>
{{ tooltipData[hoveredTopicId].description }}
</div>
<div v-if="tooltipData[hoveredTopicId].first_post" class="tooltip-first-post">
<strong>First Post:</strong>
{{ tooltipData[hoveredTopicId].first_post }}
</div>
<div class="tooltip-times">
<div>Posted: {{ formatDate(tooltipData[hoveredTopicId].topic.post_time) }}</div>
<div>Last Post: {{ formatDate(tooltipData[hoveredTopicId].topic.last_post_time) }}</div>
</div>
</div>
</div>
</body> </body>
</v-main> </v-main>
</v-app> </v-app>
</template> </template>
<style> <style scoped>
#app { #app {
font-family: Avenir, Helvetica, Arial, sans-serif; font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -171,4 +270,86 @@ const sortBy = ref([{ key: "score", order: "desc" }]); // Vuetify 3 format
background: #ffc; background: #ffc;
color: black; color: black;
} }
.deal-tooltip {
pointer-events: none;
max-width: 400px;
}
.tooltip-content {
background: #24283b;
border: 2px solid #a9b1d6;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
font-size: 13px;
color: #e0e0e0;
text-align: left;
}
.tooltip-header {
font-weight: bold;
font-size: 14px;
color: #d0d0d0;
margin-bottom: 8px;
white-space: normal;
word-wrap: break-word;
}
.tooltip-dealer {
font-size: 12px;
color: #c0c0c0;
margin-bottom: 8px;
}
.tooltip-stats {
display: flex;
gap: 12px;
margin-bottom: 8px;
font-size: 12px;
}
.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: rgba(160, 160, 160, 0.1);
border-left: 2px solid #a9b1d6;
border-radius: 2px;
font-size: 12px;
white-space: normal;
word-wrap: break-word;
max-height: 60px;
overflow-y: auto;
}
.tooltip-first-post {
margin-bottom: 8px;
padding: 8px;
background: rgba(160, 160, 160, 0.1);
border-left: 2px solid #a9b1d6;
border-radius: 2px;
font-size: 12px;
white-space: normal;
word-wrap: break-word;
max-height: 60px;
overflow-y: auto;
}
.tooltip-times {
font-size: 11px;
color: #b0b0b0;
border-top: 1px solid #555555;
padding-top: 8px;
margin-top: 8px;
}
</style> </style>