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() {
|
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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
189
src/App.vue
189
src/App.vue
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user