Skip to content

Commit

Permalink
implement achievements
Browse files Browse the repository at this point in the history
  • Loading branch information
mythz committed Apr 6, 2024
1 parent b55ef76 commit 029fd7f
Show file tree
Hide file tree
Showing 12 changed files with 166 additions and 44 deletions.
1 change: 1 addition & 0 deletions MyApp.ServiceInterface/App/CreateAnswerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ await db.InsertAsync(new Achievement
Score = 1,
CreatedDate = DateTime.UtcNow,
});
appConfig.IncrUnreadAchievementsFor(answer.CreatedBy);
}
}
}
1 change: 1 addition & 0 deletions MyApp.ServiceInterface/App/CreatePostCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ await db.InsertAsync(new Achievement
Score = 1,
CreatedDate = DateTime.UtcNow,
});
appConfig.IncrUnreadAchievementsFor(post.CreatedBy!);
}
}
}
1 change: 1 addition & 0 deletions MyApp.ServiceInterface/App/CreatePostVotesCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ await db.InsertAsync(new Achievement
Score = vote.Score > 0 ? 10 : -1, // 10 points for UpVote, -1 point for DownVote
CreatedDate = DateTime.UtcNow,
});
appConfig.IncrUnreadAchievementsFor(vote.RefUserName!);
}
}

Expand Down
1 change: 1 addition & 0 deletions MyApp.ServiceInterface/App/MarkAsReadCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public async Task ExecuteAsync(MarkAsRead request)
appConfig.UsersUnreadNotifications[userName] = (int) await db.CountAsync(
db.From<Notification>().Where(x => x.UserName == userName && !x.Read));
}
// Mark all achievements as read isn't used, they're auto reset after viewed
if (request.AllAchievements == true)
{
await db.UpdateOnlyAsync(() => new Achievement { Read = true }, x => x.UserName == userName);
Expand Down
42 changes: 36 additions & 6 deletions MyApp.ServiceInterface/UserServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public object Any(CreateAvatar request)
public async Task<object> Any(GetLatestNotifications request)
{
var userName = Request.GetClaimsPrincipal().GetUserName();
var notificationPosts = await Db.SelectMultiAsync<Notification,Post>(Db.From<Notification>()
var tuples = await Db.SelectMultiAsync<Notification,Post>(Db.From<Notification>()
.Join<Post>()
.Where(x => x.UserName == userName)
.OrderByDescending(x => x.Id)
Expand All @@ -151,23 +151,53 @@ Notification Merge(Notification notification, Post post)
return notification;
}

var results = notificationPosts.Map(x => Merge(x.Item1, x.Item2));
var results = tuples.Map(x => Merge(x.Item1, x.Item2));

return new GetLatestNotificationsResponse
{
Results = results
};
}

public class SumAchievement
{
public int PostId { get; set; }
public string RefId { get; set; }
public int Score { get; set; }
public DateTime CreatedDate { get; set; }
public string Title { get; set; }
public string Slug { get; set; }
}

public async Task<object> Any(GetLatestAchievements request)
{
var userName = Request.GetClaimsPrincipal().GetUserName();

var sumAchievements = await Db.SelectAsync<SumAchievement>(
@"SELECT A.PostId, A.RefId, Sum(A.Score) AS Score, Max(A.CreatedDate) AS CreatedDate, P.Title, p.Slug
FROM Achievement A LEFT JOIN Post P on (A.PostId = P.Id)
WHERE UserName = @userName
GROUP BY A.PostId, A.RefId
LIMIT 30", new { userName });

var i = 0;
var results = sumAchievements.Map(x => new Achievement
{
Id = ++i,
PostId = x.PostId,
RefId = x.RefId,
Title = x.Title.SubstringWithEllipsis(0,100),
Score = x.Score,
CreatedDate = x.CreatedDate,
Href = $"/questions/{x.PostId}/{x.Slug}",
});

// Reset everytime they view the latest achievements
appConfig.UsersUnreadAchievements[userName!] = 0;

return new GetLatestAchievementsResponse
{
Results = await Db.SelectAsync(Db.From<Achievement>()
.Where(x => x.UserName == userName)
.OrderByDescending(x => x.Id)
.Take(30))
Results = results
};
}

Expand Down
6 changes: 6 additions & 0 deletions MyApp/Components/Account/Pages/Register.razor
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@

public async Task RegisterUser(EditContext editContext)
{
if (char.IsDigit(Input.UserName[0]))
{
identityErrors = [new() { Code = "InvalidUserName", Description = "Username can't start with a digit" }];
return;
}

var user = CreateUser();
user.LastLoginIp = HttpContext.GetRemoteIp();
user.LastLoginDate = DateTime.UtcNow;
Expand Down
12 changes: 5 additions & 7 deletions MyApp/Components/Shared/Header.razor
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,15 @@
</li>
<li class="relative flex flex-wrap just-fu-start m-0">
<div onclick="toggleNotifications(this)" class="select-none group relative hover:bg-gray-100 dark:hover:bg-gray-800 p-4 cursor-pointer">
<svg class="w-6 h-6 text-gray-400 group-hover:text-gray-500 dark:group-hover:text-sky-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14v-3h-3q-.75.95-1.787 1.475T12 18q-1.175 0-2.212-.525T8 16H5zm7-3q.95 0 1.725-.55T14.8 14H19V5H5v9h4.2q.3.9 1.075 1.45T12 16m-7 3h14z"/>
</svg>
<svg class=@CssUtils.ClassNames("absolute right-1 top-1 h-4 w-4", AppConfig.HasUnreadNotifications(HttpContext?.User.GetUserName()) ? "text-red-500" : "text-transparent") viewBox="0 0 32 32"><circle cx="16" cy="16" r="8" fill="currentColor"/></svg>
<svg class="w-6 h-6 text-gray-400 group-hover:text-gray-500 dark:group-hover:text-sky-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14v-3h-3q-.75.95-1.787 1.475T12 18q-1.175 0-2.212-.525T8 16H5zm7-3q.95 0 1.725-.55T14.8 14H19V5H5v9h4.2q.3.9 1.075 1.45T12 16m-7 3h14z"/></svg>
<svg id="new-notifications" class=@CssUtils.ClassNames("absolute right-1 top-1 h-4 w-4", AppConfig.HasUnreadNotifications(HttpContext?.User.GetUserName()) ? "text-red-500" : "text-transparent") viewBox="0 0 32 32"><circle cx="16" cy="16" r="8" fill="currentColor"/></svg>
</div>
<div id="notifications-menu"></div>
</li>
<li onclick="toggleAchievements(this)" class="mr-2 relative flex flex-wrap just-fu-start m-0">
<div class="group relative hover:bg-gray-100 dark:hover:bg-gray-800 p-4 cursor-pointer">
<li class="mr-2 relative flex flex-wrap just-fu-start m-0">
<div onclick="toggleAchievements(this)" class="select-none group relative hover:bg-gray-100 dark:hover:bg-gray-800 p-4 cursor-pointer">
<svg class="w-6 h-6 cursor-pointer text-gray-400 group-hover:text-gray-500 dark:group-hover:text-sky-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M8 3h8v8h6v10H2V9h6zm2 16h4V5h-4zm6 0h4v-6h-4zm-8 0v-8H4v8z"/></svg>
<svg class=@CssUtils.ClassNames("absolute right-1 top-1 h-4 w-4", AppConfig.HasUnreadAchievements(HttpContext?.User.GetUserName()) ? "text-red-500" : "text-transparent") viewBox="0 0 32 32"><circle cx="16" cy="16" r="8" fill="currentColor"/></svg>
<svg id="new-achievements" class=@CssUtils.ClassNames("absolute right-1 top-1 h-4 w-4", AppConfig.HasUnreadAchievements(HttpContext?.User.GetUserName()) ? "text-red-500" : "text-transparent") viewBox="0 0 32 32"><circle cx="16" cy="16" r="8" fill="currentColor"/></svg>
</div>
<div id="achievements-menu"></div>
</li>
Expand Down
4 changes: 0 additions & 4 deletions MyApp/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1369,10 +1369,6 @@ select{
height: 100%;
}

.h-\[20rem\] {
height: 20rem;
}

.max-h-24 {
max-height: 6rem;
}
Expand Down
23 changes: 16 additions & 7 deletions MyApp/wwwroot/lib/js/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,21 @@ if (clearMetadata) {
})
}

function highlightElement(id) {
const el = document.getElementById(id)
if (el) {
el.classList.add('highlighted')
el.scrollIntoView('smooth')
}
}

if (location.hash) {
setTimeout(() => {
const el = document.getElementById(location.hash.substring(1))
if (el) {
el.classList.add('highlighted')
el.scrollIntoView('smooth')
highlightElement(location.hash.substring(1))
}

document.addEventListener('DOMContentLoaded', () =>
Blazor.addEventListener('enhancedload', (e) => {
if (location.hash) {
highlightElement(location.hash.substring(1))
}
}, 500)
}
}))
6 changes: 4 additions & 2 deletions MyApp/wwwroot/mjs/dtos.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* Options:
Date: 2024-04-06 11:14:32
Date: 2024-04-06 13:26:31
Version: 8.22
Tip: To override a DTO option, remove "//" prefix before updating
BaseUrl: https://localhost:5001
Expand Down Expand Up @@ -350,12 +350,14 @@ export class Notification {
/** @type {?string} */
refUserName;
}
/** @typedef {'Unknown'|'AnswerUpVote'|'AnswerDownVote'|'QuestionUpVote'|'QuestionDownVote'} */
/** @typedef {'Unknown'|'NewAnswer'|'AnswerUpVote'|'AnswerDownVote'|'NewQuestion'|'QuestionUpVote'|'QuestionDownVote'} */
export var AchievementType;
(function (AchievementType) {
AchievementType["Unknown"] = "Unknown"
AchievementType["NewAnswer"] = "NewAnswer"
AchievementType["AnswerUpVote"] = "AnswerUpVote"
AchievementType["AnswerDownVote"] = "AnswerDownVote"
AchievementType["NewQuestion"] = "NewQuestion"
AchievementType["QuestionUpVote"] = "QuestionUpVote"
AchievementType["QuestionDownVote"] = "QuestionDownVote"
})(AchievementType || (AchievementType = {}));
Expand Down
109 changes: 93 additions & 16 deletions MyApp/wwwroot/mjs/header.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ const NotificationsMenu = {
</div>
<div class="max-h-[20rem] overflow-auto" role="none">
<ul>
<li v-for="item in filteredResults" :key="item.id" :class="['px-2 py-2 text-xs font-normal hover:bg-indigo-100 dark:hover:bg-indigo-800 cursor-pointer border-b border-gray-200 dark:border-gray-700', item.read ? 'bg-gray-100 dark:bg-gray-700' : '']" @click="goto(item)">
<li v-for="item in filteredResults" :key="item.id" @click="goto(item)" :class="[item.read ? 'bg-gray-100 dark:bg-gray-700' : '',
'px-2 py-2 text-xs font-normal hover:bg-indigo-100 dark:hover:bg-indigo-800 cursor-pointer border-b border-gray-200 dark:border-gray-700']">
<div class="flex justify-between font-semibold text-gray-500">
<span class="">{{typeLabel(item.type)}}</span>
<span>{{formatDate(item.createdDate)}}</span>
Expand All @@ -49,7 +50,7 @@ const NotificationsMenu = {
<div class="px-2 mt-2" :title="item.summary">{{item.summary}}</div>
</li>
<li v-if="!filteredResults.length">
<div class="px-2 py-2 text-xs font-normal text-gray-500">No notifications</div>
<div class="px-2 py-2 text-xs font-normal text-gray-500">empty</div>
</li>
</ul>
</div>
Expand All @@ -71,6 +72,10 @@ const NotificationsMenu = {
show.value = !show.value
if (!show.value) timeout = setTimeout(() => hide.value = true, 700)
})
bus.subscribe('hideNotifications', () => {
show.value = false
hide.value = true
})
watch(show, () => {
transition(rule1, transition1, show.value)
})
Expand Down Expand Up @@ -111,39 +116,111 @@ const NotificationsMenu = {

const AchievementsMenu = {
template: `
<div :class="[show ? '' : 'hidden','absolute top-12 right-0 z-10 mt-1 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none']" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
<div class="py-1" role="none">
<!-- Active: "bg-gray-100 text-gray-900", Not Active: "text-gray-700" -->
<a href="#" class="text-gray-700 block px-4 py-2 text-sm" role="menuitem" tabindex="-1" id="menu-item-0">Account settings</a>
<a href="#" class="text-gray-700 block px-4 py-2 text-sm" role="menuitem" tabindex="-1" id="menu-item-1">Support</a>
<a href="#" class="text-gray-700 block px-4 py-2 text-sm" role="menuitem" tabindex="-1" id="menu-item-2">License</a>
<form method="POST" action="#" role="none">
<button type="submit" class="text-gray-700 block w-full px-4 py-2 text-left text-sm" role="menuitem" tabindex="-1" id="menu-item-3">Sign out</button>
</form>
<div v-if="!hide" :class="[transition1,'absolute top-12 right-0 z-10 mt-1 origin-top-right rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none w-[30rem]']" role="menu" aria-orientation="vertical" aria-labelledby="menu-button" tabindex="-1">
<div class="py-1 px-2 bg-gray-50 dark:bg-gray-900 flex justify-between text-sm items-center border-b border-gray-200 dark:border-gray-700">
<span class="py-1">
achievements
</span>
</div>
<div class="max-h-[20rem] overflow-auto" role="none">
<ul>
<li v-for="entry in filteredResults" :key="entry.title">
<div class="py-2 px-2 text-sm flex justify-between font-semibold border-b border-gray-200 dark:border-gray-700">
<span class="">{{entry.title}}</span>
</div>
<div v-for="item in entry.results" class="pr-2 py-2 text-xs hover:bg-indigo-100 dark:hover:bg-indigo-800 cursor-pointer border-b border-gray-200 dark:border-gray-700" @click="goto(item)">
<b v-if="item.score > 0" class="mr-2 inline-block w-10 text-right text-green-600">+ {{item.score}}</b>
<b v-else-if="item.score < 0" class="mr-2 inline-block w-10 text-right text-red-600">- {{item.score}}</b>
<span class="truncate" :title="item.title">{{item.title}}</span>
</div>
</li>
<li v-if="!filteredResults.length">
<div class="px-2 py-2 text-xs font-normal text-gray-500">empty</div>
</li>
</ul>
</div>
</div>
`,
setup(props) {
const client = useClient()
const show = ref(false)

return { show }
const results = ref([])
const hide = ref(true)

const filteredResults = computed(() => {
const to = []
const sevenDaysAgo = new Date() - 7 * 24 * 60 * 60 * 1000
const last7days = results.value.filter(x => new Date(x.createdDate) >= sevenDaysAgo)
if (last7days.length > 0) {
to.push({ title: 'Last 7 days', results: last7days })
}
const thirtyDaysAgo = new Date() - 30 * 24 * 60 * 60 * 1000
const last30days = results.value.filter(x => new Date(x.createdDate) >= thirtyDaysAgo && !last7days.includes(x))
if (last30days.length > 0) {
to.push({ title: 'Last 30 days', results: last30days })
}
const title = last7days.length + last30days.length === 0 ? 'All time' : 'Older'
const remaining = results.value.filter(x => !last7days.includes(x) && !last30days.includes(x))
if (remaining.length > 0) {
to.push({ title, results: remaining })
}
return to
})

const transition1 = ref('transform opacity-0 scale-95')
let timeout = null
bus.subscribe('toggleAchievements', () => {
clearTimeout(timeout)
hide.value = false
show.value = !show.value
if (!show.value) timeout = setTimeout(() => hide.value = true, 700)
})
bus.subscribe('hideAchievements', () => {
show.value = false
hide.value = true
})
watch(show, () => {
transition(rule1, transition1, show.value)
})

onMounted(async () => {
const api = await client.api(new GetLatestAchievements())
if (api.succeeded) {
results.value = api.response.results || []
}
})

async function goto(item) {
location.href = item.href
}

return { transition1, hide, filteredResults, formatDate, goto }
}
}


globalThis.toggleNotifications = function (el) {
function toggleNotifications(el) {
// console.log('toggleNotifications')
bus.publish('toggleNotifications')
bus.publish('hideAchievements')
}

globalThis.toggleAchievements = function (el) {
function toggleAchievements(el) {
// console.log('toggleAchievements')
bus.publish('toggleAchievements')
bus.publish('hideNotifications')
$1('#new-achievements').classList.remove('text-red-500')
$1('#new-achievements').classList.add('text-transparent')
}

globalThis.toggleNotifications = toggleNotifications
globalThis.toggleAchievements = toggleAchievements

export default {
load() {
console.log('header loaded')
globalThis.toggleNotifications = toggleNotifications
globalThis.toggleAchievements = toggleAchievements

const elNotificationsMenu = $1('#notifications-menu')
const elAchievementsMenu = $1('#achievements-menu')

Expand Down
4 changes: 2 additions & 2 deletions MyApp/wwwroot/mjs/question.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ async function loadVoting(ctx) {
const down = el.querySelector('.down')
const score = el.querySelector('.score')

const value = getValue(userPostVotes, el.id)
const value = getValue(userPostVotes, el.dataset.refid)
up.classList.toggle('text-green-600',value === 1)
up.innerHTML = value === 1 ? svgPaths.up.solid : svgPaths.up.empty
down.classList.toggle('text-green-600',value === -1)
down.innerHTML = value === -1 ? svgPaths.down.solid : svgPaths.down.empty
score.innerHTML = parseInt(score.dataset.score) + value - getValue(origPostValues, el.id)
score.innerHTML = parseInt(score.dataset.score) + value - getValue(origPostValues, el.dataset.refid)
}
function getValue(postVotes, refId) {
return (postVotes.upVoteIds.includes(refId) ? 1 : postVotes.downVoteIds.includes(refId) ? -1 : 0)
Expand Down

0 comments on commit 029fd7f

Please sign in to comment.