Skip to content

Commit 050ef18

Browse files
authored
Merge pull request TAP-GGC#129 from TAP-GGC/rating
Add Embedded Comment and Rating from Discourse Forum Post
2 parents c5e57b2 + 5f82462 commit 050ef18

7 files changed

Lines changed: 340 additions & 4 deletions

File tree

docs/projects/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,46 @@ You can follow the examples of previous projects:
7575
## Submitting your changes
7676

7777
Make sure to create a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) from your branch or fork to submit your changes. See more details in the [Github Codespaces page](../github-codespace/README.md).
78+
79+
## After Your Pull Request Is Merged (IMPORTANT)
80+
81+
Once your pull request is approved and merged into the main branch, there are a few final required steps to activate the comment and rating for your project page.
82+
83+
👀 Step 1 – Visit Your Project Page
84+
85+
Go to the TAP website and open your project page n the browser at least once for Discourse bot generating your project topic discussion
86+
87+
👀 Step 2 – Open the Forum Discussion
88+
89+
Scroll to the Comments section at the bottom of your project page. Then, click the forum link (You may need to refresh the page for the first visit).
90+
91+
![Open forum link](./forumLink.png)
92+
93+
Then, you will be navigate to the forum thread discussion of your project.
94+
95+
👀 Step 3 – Add the Rating Poll (REQUIRED)
96+
97+
Once you are inside your project’s forum topic, make sure you are logged in to the forum then click Reply to the first post.
98+
99+
![Reply to first post](./replyPost.png)
100+
101+
Paste the following exactly as shown and click Reply (note: please choose Markdown option when reply)
102+
103+
```yaml
104+
Testing comment for the first one.
105+
106+
[poll type=regular results=always public=true chartType=bar]
107+
* ⭐
108+
* ⭐⭐
109+
* ⭐⭐⭐
110+
* ⭐⭐⭐⭐
111+
* ⭐⭐⭐⭐⭐
112+
[/poll]
113+
```
114+
115+
![Poll content](./pollContent.png)
116+
117+
118+
After posting the poll, please allow at least 1 day for the website to update the content. Then, revisit your project page and confirm that the rating now show up.
119+
120+
![Rating show up on project page](./ratingAvailable.png)

docs/projects/forumLink.png

110 KB
Loading

docs/projects/pollContent.png

202 KB
Loading

docs/projects/ratingAvailable.png

92 KB
Loading

docs/projects/replyPost.png

181 KB
Loading

src/components/vue/PollRating.vue

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<template>
2+
<div class="poll-box" v-if="poll">
3+
<div class="poll-layout">
4+
5+
<!-- LEFT / TOP: big average -->
6+
<div class="poll-summary" v-if="average > 0">
7+
<div class="average-score">
8+
<span class="average-number">{{ average.toFixed(1) }}</span>
9+
<span class="average-out-of">/ 5</span>
10+
</div>
11+
<p class="meta">
12+
{{ poll.voters }} voter(s)
13+
</p>
14+
</div>
15+
16+
<!-- RIGHT / BOTTOM: detailed distribution -->
17+
<div class="poll-detail">
18+
<div
19+
v-for="opt in poll.options"
20+
:key="opt.id"
21+
class="poll-row"
22+
>
23+
<span class="label">
24+
{{ '★'.repeat(opt.stars).padEnd(5, '☆') }}
25+
</span>
26+
27+
<div class="bar-wrapper">
28+
<div class="bar" :style="{ width: opt.percent.toFixed(0) + '%' }"></div>
29+
</div>
30+
31+
<span class="percent">
32+
{{ opt.percent.toFixed(0) }}%
33+
</span>
34+
</div>
35+
</div>
36+
</div>
37+
</div>
38+
39+
<p v-else class="no-poll">No rating poll yet.</p>
40+
</template>
41+
42+
<script setup>
43+
import { computed } from 'vue'
44+
45+
const props = defineProps({
46+
poll: {
47+
type: Object,
48+
required: false,
49+
default: null,
50+
},
51+
})
52+
53+
const average = computed(() => {
54+
const p = props.poll
55+
if (!p || !p.voters) return 0
56+
57+
if (typeof p.average === 'number') return p.average
58+
59+
const totalScore = p.options.reduce(
60+
(sum, opt) => sum + opt.stars * opt.votes,
61+
0
62+
)
63+
return p.voters ? totalScore / p.voters : 0
64+
})
65+
</script>
66+
67+
<style scoped>
68+
.poll-box {
69+
width: 100%;
70+
max-width: 960px;
71+
margin: 1.5rem auto;
72+
padding: 0 1rem;
73+
}
74+
75+
.poll-layout {
76+
display: flex;
77+
flex-direction: column;
78+
gap: 1.5rem;
79+
}
80+
81+
/* On medium+ screens, switch to side-by-side columns */
82+
@media (min-width: 768px) {
83+
.poll-layout {
84+
flex-direction: row;
85+
align-items: flex-start;
86+
}
87+
88+
.poll-summary,
89+
.poll-detail {
90+
flex: 1 1 0;
91+
}
92+
}
93+
94+
/* LEFT / TOP */
95+
.poll-summary {
96+
display: flex;
97+
flex-direction: column;
98+
align-items: center;
99+
text-align: center;
100+
}
101+
102+
.average-score {
103+
display: flex;
104+
align-items: baseline;
105+
gap: 0.25rem;
106+
margin-bottom: 0.75rem;
107+
}
108+
109+
.average-number {
110+
font-size: clamp(6.4rem, 5vw, 3.2rem);
111+
font-weight: 700;
112+
}
113+
114+
.average-out-of {
115+
font-size: 1.1rem;
116+
opacity: 0.8;
117+
}
118+
119+
.meta {
120+
font-size: 0.85rem;
121+
opacity: 0.7;
122+
}
123+
124+
/* RIGHT / BOTTOM */
125+
.poll-detail {
126+
width: 100%;
127+
}
128+
129+
.poll-row {
130+
display: flex;
131+
align-items: center;
132+
gap: 0.5rem;
133+
margin: 0.3rem 0;
134+
}
135+
136+
.label {
137+
min-width: 3.5rem;
138+
font-family: system-ui, sans-serif;
139+
color: #facc15;
140+
font-size: 1rem;
141+
}
142+
143+
.bar-wrapper {
144+
flex: 1;
145+
height: 0.6rem;
146+
background: #333;
147+
border-radius: 999px;
148+
overflow: hidden;
149+
}
150+
151+
.bar {
152+
height: 100%;
153+
background: #22c55e;
154+
}
155+
156+
.percent {
157+
min-width: 3rem;
158+
text-align: right;
159+
}
160+
161+
.no-poll {
162+
font-size: 0.9rem;
163+
opacity: 0.7;
164+
text-align: center;
165+
}
166+
</style>

src/pages/projects/[...projectId].astro

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { getCollection, } from 'astro:content';
3232
3333
import Crumbs from "../../components/Crumbs.vue"
3434
35+
import PollRating from '../../components/vue/PollRating.vue';
36+
3537
// 1. Generate a new path for every collection entry
3638
// Goal 1: Make it redirect to the projects.astro page, using the parameters as values for
3739
export async function getStaticPaths() {
@@ -80,6 +82,7 @@ function getNameFromID(id, type = 'student') {
8082
return match?.data?.name || formatIDtoName(id);
8183
}
8284
85+
8386
//Technologies card entries
8487
const allTechs = await getCollection('technologies');
8588
const techEntries = allTechs.filter(tech =>
@@ -96,6 +99,64 @@ for (const tech of techEntries) {
9699
// const studentData = project.data.students ? await getEntries(project.data.students) : [];
97100
// const instructorData = project.data.instructors.slug ? await getEntries(project.data.instructors) : "Instructor-tbd";
98101
102+
const DISCOURSE_BASE = 'https://forum.tapggc.org';
103+
104+
function makeForumSlug(shortTitle, title) {
105+
const raw = `${shortTitle}-${title}`;
106+
107+
return raw
108+
.trim()
109+
.toLowerCase()
110+
.replace(/[^a-z0-9\s-]/g, '') // strip punctuation
111+
.replace(/\s+/g, '-') // spaces -> hyphens
112+
.replace(/-+/g, '-') // collapse multiple dashes
113+
}
114+
115+
let poll: any = null;
116+
117+
if (project) {
118+
const shortTitle = project.data.shortTitle || project.data.id;
119+
const title = project.data.title;
120+
121+
const slug = makeForumSlug(shortTitle, title);
122+
const topicUrl = `${DISCOURSE_BASE}/t/${slug}.json`;
123+
124+
try {
125+
const topic = await fetch(topicUrl).then((r) => r.json());
126+
const stream = topic?.post_stream?.stream || [];
127+
128+
for (const postId of stream) {
129+
const postUrl = `${DISCOURSE_BASE}/posts/${postId}.json`;
130+
const post = await fetch(postUrl).then((r) => r.json());
131+
132+
if (Array.isArray(post.polls) && post.polls.length > 0) {
133+
const p = post.polls[0];
134+
const voters = p.voters || 0;
135+
136+
const options = p.options.map((opt) => {
137+
const html = String(opt.html ?? '')
138+
const imgMatches = html.match(/<img\b[^>]*>/g) || []
139+
const stars = imgMatches.length || 0;
140+
141+
return {
142+
stars,
143+
votes: opt.votes,
144+
percent: voters ? (opt.votes / voters) * 100 : 0,
145+
}
146+
});
147+
148+
const totalScore = options.reduce((sum, opt) => sum + opt.stars * opt.votes, 0);
149+
const average = voters ? totalScore / voters : 0;
150+
151+
poll = { voters, average, options };
152+
break;
153+
}
154+
}
155+
} catch (err) {
156+
console.error(`Failed to fetch poll for slug ${slug}:`, err)
157+
}
158+
}
159+
99160
100161
// Github button
101162
import { Button, ButtonGroup } from 'agnostic-vue';
@@ -248,9 +309,57 @@ imageLogoDark = imageLogoTrans ? imageLogoTrans : imageLogoDark;
248309
))}
249310
</div>
250311
</>
251-
)}
312+
)}
313+
314+
<section class="rating-section">
315+
<h2 style="text-align: center; margin-top: 3rem;">Rating</h2>
316+
{poll ? (
317+
<PollRating poll={poll} client:load />
318+
) : (
319+
<p>No rating available for this project yet.</p>
320+
)}
321+
</section>
322+
323+
<h2 style="text-align: center; margin-top: 3rem;">Comments</h2>
324+
<div id="discourse-comments"></div>
252325
</div>
253-
326+
327+
<script is:inline>
328+
var DiscourseEmbed = {
329+
discourseUrl: "https://forum.tapggc.org/",
330+
discourseEmbedUrl: window.location.href
331+
};
332+
333+
(function() {
334+
var d = document.createElement("script"); d.type = "text/javascript"; d.async = true;
335+
d.src = DiscourseEmbed.discourseUrl + "javascripts/embed.js";
336+
(document.getElementsByTagName("head")[0] ||
337+
document.getElementsByTagName("body")[0]).appendChild(d);
338+
})();
339+
</script>
340+
341+
<script is:inline>
342+
function fixDiscourseIframe() {
343+
const iframe = document.getElementById("discourse-embed-frame");
344+
if (!iframe) return;
345+
346+
// allow scrolling instead of "no"
347+
iframe.removeAttribute("scrolling");
348+
iframe.style.overflow = "visible";
349+
350+
// enforce a minimum height
351+
const h = parseInt(iframe.style.height || iframe.height || 0, 10);
352+
if (!h || h < 600) {
353+
iframe.style.height = "800px";
354+
}
355+
}
356+
357+
// run a few times because embed.js runs async
358+
setTimeout(fixDiscourseIframe, 500);
359+
setTimeout(fixDiscourseIframe, 1500);
360+
setInterval(fixDiscourseIframe, 3000);
361+
</script>
362+
254363
<style>
255364

256365

@@ -319,12 +428,31 @@ imageLogoDark = imageLogoTrans ? imageLogoTrans : imageLogoDark;
319428

320429
.related-projects-container {
321430
display: grid;
322-
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
431+
grid-template-columns: 1fr;
323432
gap: 1.5rem;
324433
margin-top: 1rem;
325434
padding: 0 1rem;
326435
}
327436

437+
@media (min-width: 700px) {
438+
.related-projects-container {
439+
grid-template-columns: repeat(2, 1fr);
440+
}
441+
}
442+
443+
/* ensure the discourse comment to show all */
444+
#discourse-comments iframe {
445+
width: 100% !important;
446+
min-height: 800px !important;
447+
background-color: black;
448+
}
449+
450+
#discourse-comments {
451+
width: 100%;
452+
background-color: black;
453+
overflow: visible;
454+
}
455+
328456
.tech-card-grid {
329457
display: grid;
330458
grid-auto-flow: row;
@@ -333,7 +461,6 @@ imageLogoDark = imageLogoTrans ? imageLogoTrans : imageLogoDark;
333461
margin-top: 1rem;
334462
justify-items: center;
335463
}
336-
337464
</style>
338465
</TAPDefaultLayout>
339466

0 commit comments

Comments
 (0)