mirror of
https://github.com/davegallant/rfd-fyi.git
synced 2026-03-03 09:36:35 +00:00
Add tooltip when hovering over deal
This commit is contained in:
@@ -63,6 +63,7 @@ func (a *App) Run(httpPort string) {
|
||||
|
||||
func (a *App) initializeRoutes() {
|
||||
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) {
|
||||
@@ -87,6 +88,90 @@ func (a *App) listTopics(w http.ResponseWriter, r *http.Request) {
|
||||
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() {
|
||||
for {
|
||||
log.Info().Msg("Refreshing topics")
|
||||
|
||||
@@ -27,3 +27,9 @@ type Offer struct {
|
||||
DealerName string `json:"dealer_name"`
|
||||
Url string `json:"url"`
|
||||
} // @name Offer
|
||||
|
||||
type TopicDetails struct {
|
||||
Topic Topic `json:"topic"`
|
||||
Description string `json:"description"`
|
||||
FirstPost string `json:"first_post"`
|
||||
} // @name TopicDetails
|
||||
|
||||
189
src/App.vue
189
src/App.vue
@@ -1,11 +1,15 @@
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import Loading from "vue-loading-overlay";
|
||||
import { install } from "@github/hotkey";
|
||||
import { ref, reactive } from "vue";
|
||||
|
||||
import "vue-loading-overlay/dist/css/index.css";
|
||||
import { ref } from "vue";
|
||||
|
||||
// Configure day.js with UTC support
|
||||
dayjs.extend(utc);
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -14,6 +18,10 @@ export default {
|
||||
filter: window.location.href.split("filter=")[1] || "",
|
||||
sortColumn: this.sortColumn,
|
||||
topics: [],
|
||||
hoveredTopicId: null,
|
||||
tooltipData: {},
|
||||
loadingTooltip: {},
|
||||
tooltipPosition: { x: 0, y: 0 },
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@@ -33,6 +41,53 @@ export default {
|
||||
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) {
|
||||
this.$refs.filter.blur();
|
||||
history.pushState(
|
||||
@@ -55,7 +110,8 @@ export default {
|
||||
computed: {
|
||||
formatDate() {
|
||||
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() {
|
||||
@@ -88,7 +144,6 @@ export default {
|
||||
const headers = [
|
||||
{ title: "Deal", value: "title", align: "center" },
|
||||
{ title: "Score", value: "score", align: "center", sortable: true },
|
||||
{ title: "Views", value: "total_views", align: "center", sortable: true },
|
||||
{
|
||||
title: "Last Post",
|
||||
value: "last_post_time",
|
||||
@@ -123,6 +178,9 @@ const sortBy = ref([{ key: "score", order: "desc" }]); // Vuetify 3 format
|
||||
<a
|
||||
:href="`https://forums.redflagdeals.com${item.web_path}`"
|
||||
target="_blank"
|
||||
@mouseenter="handleTitleHover(item, $event)"
|
||||
@mouseleave="handleTitleLeave"
|
||||
@mousemove="handleMouseMove"
|
||||
v-html="
|
||||
highlightMatches(
|
||||
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" />
|
||||
</template>
|
||||
</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>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -171,4 +270,86 @@ const sortBy = ref([{ key: "score", order: "desc" }]); // Vuetify 3 format
|
||||
background: #ffc;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user