summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-06 12:12:17 +0530
committerBobby <[email protected]>2026-03-06 12:12:17 +0530
commitb947bd40d50ddc566ee859a20304655f116c9bf8 (patch)
tree8fa08e697cd73e5eaa57291e8ae4ca9cbcdac946
parent994f18d65e4605502342b434acebef25589bb062 (diff)
downloadpagoda-b947bd40d50ddc566ee859a20304655f116c9bf8.tar.xz
pagoda-b947bd40d50ddc566ee859a20304655f116c9bf8.zip
feat: implement user statistics and heartbeat functionality with UI updates
-rw-r--r--garden/src/components/Layout.tsx70
-rw-r--r--garden/src/pages/home.tsx3
-rw-r--r--garden/src/store/auth.ts9
-rw-r--r--garden/src/store/stats.ts14
-rw-r--r--garden/src/styles/layout.css215
-rw-r--r--garden/src/types/stats.ts13
-rw-r--r--shrine/controllers/auth.go4
-rw-r--r--shrine/controllers/responses.go4
-rw-r--r--shrine/controllers/stats.go30
-rw-r--r--shrine/models/user.go9
-rw-r--r--shrine/repositories/user.go28
-rw-r--r--shrine/router/auth.go1
-rw-r--r--shrine/router/stats.go13
-rw-r--r--shrine/types/response.go14
-rw-r--r--shrine/utils/auth/auth.go6
-rw-r--r--shrine/utils/shortcuts/response.go3
16 files changed, 374 insertions, 62 deletions
diff --git a/garden/src/components/Layout.tsx b/garden/src/components/Layout.tsx
index e5d5bf8..4ff2e8e 100644
--- a/garden/src/components/Layout.tsx
+++ b/garden/src/components/Layout.tsx
@@ -1,8 +1,9 @@
-import { type JSX, Show, onMount } from "solid-js";
+import { type JSX, Show, For, onMount, onCleanup, createEffect } from "solid-js";
import { A } from "@solidjs/router";
import Sidebar from "./Sidebar";
import NavSection from "./NavSection";
import { auth } from "../store/auth";
+import { stats } from "../store/stats";
import { UserRole } from "../types/roles";
interface LayoutProps {
@@ -10,7 +11,22 @@ interface LayoutProps {
}
export default function Layout(props: LayoutProps) {
- onMount(() => auth.initialize());
+ let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
+
+ onMount(() => {
+ auth.initialize();
+ stats.load();
+ });
+
+ createEffect(() => {
+ clearInterval(heartbeatInterval);
+ if (auth.user()) {
+ auth.heartbeat();
+ heartbeatInterval = setInterval(() => auth.heartbeat(), 2 * 60 * 1000);
+ }
+ });
+
+ onCleanup(() => clearInterval(heartbeatInterval));
return (
<>
@@ -23,12 +39,12 @@ export default function Layout(props: LayoutProps) {
<A href="/" class="top-nav-link" data-accent="cyan" activeClass="active" end>Home</A>
<A href="/districts" class="top-nav-link" data-accent="green" activeClass="active">Districts</A>
<A href="/forums" class="top-nav-link" data-accent="pink" activeClass="active">Forums</A>
- <A href="/chat" class="top-nav-link" data-accent="yellow" activeClass="active">Chat</A>
+ <A href="/tavern" class="top-nav-link" data-accent="yellow" activeClass="active">Tavern</A>
<A href="/bazaar" class="top-nav-link" data-accent="purple" activeClass="active">Bazaar</A>
</nav>
<div class="search-bar">
- <input type="text" placeholder="Search members, posts, districts..." />
+ <input type="text" placeholder="Search citizens, posts, districts..." />
</div>
<div class="site-main">
@@ -43,11 +59,9 @@ export default function Layout(props: LayoutProps) {
}>
{((user) => (
<>
- <li><A href="/account">Account</A></li>
- <li><A href={`/u/${user.username}`}>My Page</A></li>
+ <li><A href={`/u/${user.username}`}>My Domain</A></li>
<li><A href="/letters">Letters</A></li>
- <li><A href="/notifications">Notifications</A></li>
- <li><A href="/friends">Friends</A></li>
+ <li><A href="/account">Account</A></li>
<li><A href="/account/settings">Settings</A></li>
<li><button type="button" class="sidebar-logout" onClick={() => auth.logout()}>Log Out</button></li>
</>
@@ -57,13 +71,17 @@ export default function Layout(props: LayoutProps) {
</NavSection>
<NavSection title="Community" accent="cyan">
<ul>
- <li><A href="/members">Members</A></li>
- <li><A href="/online">Who's Online</A></li>
- <li><A href="/random">Random Member</A></li>
+ <li><A href="/citizens">Citizens</A></li>
<li><A href="/clubs">Clubs</A></li>
<li><A href="/interests">Interests</A></li>
</ul>
</NavSection>
+ <NavSection title="Explore" accent="yellow">
+ <ul>
+ <li><A href="/online">Who's Online</A></li>
+ <li><A href="/random">Random Domain</A></li>
+ </ul>
+ </NavSection>
<NavSection title="Services" accent="green">
<ul>
<li><A href="/caravan">Caravan</A></li>
@@ -96,18 +114,34 @@ export default function Layout(props: LayoutProps) {
<Sidebar>
<NavSection title="Statistics" accent="yellow">
<ul>
- <li>Members: —</li>
- <li>Online: —</li>
+ <li>Citizens: {stats.data()?.citizens ?? "—"}</li>
+ <li>Online: {stats.data()?.online ?? "—"}</li>
<li>Posts Today: —</li>
- <li>Newest: —</li>
</ul>
</NavSection>
- <NavSection title="New Members" accent="cyan">
+ <NavSection title="New Citizens" accent="cyan">
+ <ul>
+ <Show when={stats.data()?.newest_citizens?.length} fallback={
+ <li class="placeholder">Be the first to join!</li>
+ }>
+ <For each={stats.data()?.newest_citizens}>
+ {(citizen) => <li><A href={`/u/${citizen.username}`}>{citizen.display_name}</A></li>}
+ </For>
+ </Show>
+ </ul>
+ </NavSection>
+ <NavSection title="Who's Online" accent="green">
<ul>
- <li class="placeholder">Be the first to join!</li>
+ <Show when={stats.data()?.online_citizens?.length} fallback={
+ <li class="placeholder">No one online.</li>
+ }>
+ <For each={stats.data()?.online_citizens}>
+ {(citizen) => <li><A href={`/u/${citizen.username}`}>{citizen.display_name}</A></li>}
+ </For>
+ </Show>
</ul>
</NavSection>
- <NavSection title="Birthdays" accent="green">
+ <NavSection title="Birthdays" accent="pink">
<ul>
<li class="placeholder">No birthdays today.</li>
</ul>
@@ -133,7 +167,7 @@ export default function Layout(props: LayoutProps) {
<A href="/terms">Terms</A>
<A href="/contact">Contact</A>
</nav>
- <p>&copy; {new Date().getFullYear()} Pagoda. Brought to you by <a href="https://shi.foo" target="_blank" rel="noopener noreferrer">shi.foo</a>.</p>
+ <p>&copy; {new Date().getFullYear()} Pagoda. Brought to you by <a href="https://shi.foo" target="_blank" rel="noopener noreferrer">shi.foo</a>. Powered by <a href="https://nekoweb.org" target="_blank" rel="noopener noreferrer">Nekoweb</a>.</p>
</footer>
</>
);
diff --git a/garden/src/pages/home.tsx b/garden/src/pages/home.tsx
index 96a2fa2..3e07a84 100644
--- a/garden/src/pages/home.tsx
+++ b/garden/src/pages/home.tsx
@@ -1,8 +1,7 @@
export default function Home() {
return (
<section>
- <p>Welcome to <em>Pagoda</em>! A community for the small web, powered by <a href="https://nekoweb.org" target="_blank" rel="noopener noreferrer">nekoweb</a>. Explore districts, join forums, chat with fellow web enthusiasts, and customize your own page.</p>
- <p>Pagoda is currently under construction. Check back soon.</p>
+ <p></p>
</section>
);
} \ No newline at end of file
diff --git a/garden/src/store/auth.ts b/garden/src/store/auth.ts
index 7dca3c2..915e4de 100644
--- a/garden/src/store/auth.ts
+++ b/garden/src/store/auth.ts
@@ -88,6 +88,13 @@ async function reactivate(email: string): Promise<string | null> {
return (response.data as ErrorResponse).error;
}
+async function heartbeat() {
+ const stored = token();
+ if (stored) {
+ await api("/auth/heartbeat", { method: "POST", token: stored });
+ }
+}
+
async function logout() {
const stored = token();
if (stored) {
@@ -98,4 +105,4 @@ async function logout() {
setUser(null);
}
-export const auth = { user, token, loading, initialize, login, register, verify, reactivate, logout }; \ No newline at end of file
+export const auth = { user, token, loading, initialize, login, register, verify, reactivate, heartbeat, logout }; \ No newline at end of file
diff --git a/garden/src/store/stats.ts b/garden/src/store/stats.ts
new file mode 100644
index 0000000..e13164f
--- /dev/null
+++ b/garden/src/store/stats.ts
@@ -0,0 +1,14 @@
+import { createSignal } from "solid-js";
+import { api } from "../api";
+import type { Stats } from "../types/stats";
+
+const [data, setData] = createSignal<Stats | null>(null);
+
+async function load() {
+ const response = await api<Stats>("/stats/");
+ if (response.ok) {
+ setData(response.data);
+ }
+}
+
+export const stats = { data, load }; \ No newline at end of file
diff --git a/garden/src/styles/layout.css b/garden/src/styles/layout.css
index 19c0235..4e55e9b 100644
--- a/garden/src/styles/layout.css
+++ b/garden/src/styles/layout.css
@@ -25,7 +25,7 @@ a:hover {
}
.site-header {
- padding: 16px 0 8px;
+ padding: 0px 0 16px 0;
text-align: center;
position: relative;
}
@@ -93,17 +93,65 @@ a:hover {
border-right: none;
}
-.top-nav-link[data-accent="cyan"]:hover { border-top-color: var(--color-cyan); color: var(--color-cyan); background: color-mix(in srgb, var(--color-cyan) 6%, transparent); }
-.top-nav-link[data-accent="green"]:hover { border-top-color: var(--color-green); color: var(--color-green); background: color-mix(in srgb, var(--color-green) 6%, transparent); }
-.top-nav-link[data-accent="pink"]:hover { border-top-color: var(--color-pink); color: var(--color-pink); background: color-mix(in srgb, var(--color-pink) 6%, transparent); }
-.top-nav-link[data-accent="yellow"]:hover { border-top-color: var(--color-yellow); color: var(--color-yellow); background: color-mix(in srgb, var(--color-yellow) 6%, transparent); }
-.top-nav-link[data-accent="purple"]:hover { border-top-color: var(--color-purple); color: var(--color-purple); background: color-mix(in srgb, var(--color-purple) 6%, transparent); }
+.top-nav-link[data-accent="cyan"]:hover {
+ border-top-color: var(--color-cyan);
+ color: var(--color-cyan);
+ background: color-mix(in srgb, var(--color-cyan) 6%, transparent);
+}
+
+.top-nav-link[data-accent="green"]:hover {
+ border-top-color: var(--color-green);
+ color: var(--color-green);
+ background: color-mix(in srgb, var(--color-green) 6%, transparent);
+}
+
+.top-nav-link[data-accent="pink"]:hover {
+ border-top-color: var(--color-pink);
+ color: var(--color-pink);
+ background: color-mix(in srgb, var(--color-pink) 6%, transparent);
+}
+
+.top-nav-link[data-accent="yellow"]:hover {
+ border-top-color: var(--color-yellow);
+ color: var(--color-yellow);
+ background: color-mix(in srgb, var(--color-yellow) 6%, transparent);
+}
+
+.top-nav-link[data-accent="purple"]:hover {
+ border-top-color: var(--color-purple);
+ color: var(--color-purple);
+ background: color-mix(in srgb, var(--color-purple) 6%, transparent);
+}
-.top-nav-link[data-accent="cyan"].active { border-top-color: var(--color-cyan); color: var(--color-cyan); background: color-mix(in srgb, var(--color-cyan) 10%, transparent); }
-.top-nav-link[data-accent="green"].active { border-top-color: var(--color-green); color: var(--color-green); background: color-mix(in srgb, var(--color-green) 10%, transparent); }
-.top-nav-link[data-accent="pink"].active { border-top-color: var(--color-pink); color: var(--color-pink); background: color-mix(in srgb, var(--color-pink) 10%, transparent); }
-.top-nav-link[data-accent="yellow"].active { border-top-color: var(--color-yellow); color: var(--color-yellow); background: color-mix(in srgb, var(--color-yellow) 10%, transparent); }
-.top-nav-link[data-accent="purple"].active { border-top-color: var(--color-purple); color: var(--color-purple); background: color-mix(in srgb, var(--color-purple) 10%, transparent); }
+.top-nav-link[data-accent="cyan"].active {
+ border-top-color: var(--color-cyan);
+ color: var(--color-cyan);
+ background: color-mix(in srgb, var(--color-cyan) 10%, transparent);
+}
+
+.top-nav-link[data-accent="green"].active {
+ border-top-color: var(--color-green);
+ color: var(--color-green);
+ background: color-mix(in srgb, var(--color-green) 10%, transparent);
+}
+
+.top-nav-link[data-accent="pink"].active {
+ border-top-color: var(--color-pink);
+ color: var(--color-pink);
+ background: color-mix(in srgb, var(--color-pink) 10%, transparent);
+}
+
+.top-nav-link[data-accent="yellow"].active {
+ border-top-color: var(--color-yellow);
+ color: var(--color-yellow);
+ background: color-mix(in srgb, var(--color-yellow) 10%, transparent);
+}
+
+.top-nav-link[data-accent="purple"].active {
+ border-top-color: var(--color-purple);
+ color: var(--color-purple);
+ background: color-mix(in srgb, var(--color-purple) 10%, transparent);
+}
.search-bar {
width: var(--width-container);
@@ -199,12 +247,29 @@ a:hover {
border: 1px solid var(--color-border);
}
-.nav-section[data-accent="cyan"] { border-top: 2px solid var(--color-cyan); }
-.nav-section[data-accent="green"] { border-top: 2px solid var(--color-green); }
-.nav-section[data-accent="pink"] { border-top: 2px solid var(--color-pink); }
-.nav-section[data-accent="yellow"] { border-top: 2px solid var(--color-yellow); }
-.nav-section[data-accent="purple"] { border-top: 2px solid var(--color-purple); }
-.nav-section[data-accent="red"] { border-top: 2px solid var(--color-red); }
+.nav-section[data-accent="cyan"] {
+ border-top: 2px solid var(--color-cyan);
+}
+
+.nav-section[data-accent="green"] {
+ border-top: 2px solid var(--color-green);
+}
+
+.nav-section[data-accent="pink"] {
+ border-top: 2px solid var(--color-pink);
+}
+
+.nav-section[data-accent="yellow"] {
+ border-top: 2px solid var(--color-yellow);
+}
+
+.nav-section[data-accent="purple"] {
+ border-top: 2px solid var(--color-purple);
+}
+
+.nav-section[data-accent="red"] {
+ border-top: 2px solid var(--color-red);
+}
.nav-section-header {
background: var(--color-panel-header);
@@ -218,12 +283,29 @@ a:hover {
color: var(--color-text-bright);
}
-.nav-section[data-accent="cyan"] .nav-section-header { color: var(--color-cyan); }
-.nav-section[data-accent="green"] .nav-section-header { color: var(--color-green); }
-.nav-section[data-accent="pink"] .nav-section-header { color: var(--color-pink); }
-.nav-section[data-accent="yellow"] .nav-section-header { color: var(--color-yellow); }
-.nav-section[data-accent="purple"] .nav-section-header { color: var(--color-purple); }
-.nav-section[data-accent="red"] .nav-section-header { color: var(--color-red); }
+.nav-section[data-accent="cyan"] .nav-section-header {
+ color: var(--color-cyan);
+}
+
+.nav-section[data-accent="green"] .nav-section-header {
+ color: var(--color-green);
+}
+
+.nav-section[data-accent="pink"] .nav-section-header {
+ color: var(--color-pink);
+}
+
+.nav-section[data-accent="yellow"] .nav-section-header {
+ color: var(--color-yellow);
+}
+
+.nav-section[data-accent="purple"] .nav-section-header {
+ color: var(--color-purple);
+}
+
+.nav-section[data-accent="red"] .nav-section-header {
+ color: var(--color-red);
+}
.nav-section-body ul {
list-style: none;
@@ -243,35 +325,86 @@ a:hover {
color: var(--color-text-muted);
}
-.nav-section[data-accent="cyan"] .nav-section-body li::before { color: var(--color-cyan); }
-.nav-section[data-accent="green"] .nav-section-body li::before { color: var(--color-green); }
-.nav-section[data-accent="pink"] .nav-section-body li::before { color: var(--color-pink); }
-.nav-section[data-accent="yellow"] .nav-section-body li::before { color: var(--color-yellow); }
-.nav-section[data-accent="purple"] .nav-section-body li::before { color: var(--color-purple); }
-.nav-section[data-accent="red"] .nav-section-body li::before { color: var(--color-red); }
+.nav-section[data-accent="cyan"] .nav-section-body li::before {
+ color: var(--color-cyan);
+}
+
+.nav-section[data-accent="green"] .nav-section-body li::before {
+ color: var(--color-green);
+}
+
+.nav-section[data-accent="pink"] .nav-section-body li::before {
+ color: var(--color-pink);
+}
+
+.nav-section[data-accent="yellow"] .nav-section-body li::before {
+ color: var(--color-yellow);
+}
+
+.nav-section[data-accent="purple"] .nav-section-body li::before {
+ color: var(--color-purple);
+}
+
+.nav-section[data-accent="red"] .nav-section-body li::before {
+ color: var(--color-red);
+}
.nav-section-body li a {
color: var(--color-link);
text-decoration: none;
}
-.nav-section[data-accent="cyan"] .nav-section-body li a:hover { color: var(--color-cyan); }
-.nav-section[data-accent="green"] .nav-section-body li a:hover { color: var(--color-green); }
-.nav-section[data-accent="pink"] .nav-section-body li a:hover { color: var(--color-pink); }
-.nav-section[data-accent="yellow"] .nav-section-body li a:hover { color: var(--color-yellow); }
-.nav-section[data-accent="purple"] .nav-section-body li a:hover { color: var(--color-purple); }
-.nav-section[data-accent="red"] .nav-section-body li a:hover { color: var(--color-red); }
+.nav-section[data-accent="cyan"] .nav-section-body li a:hover {
+ color: var(--color-cyan);
+}
+
+.nav-section[data-accent="green"] .nav-section-body li a:hover {
+ color: var(--color-green);
+}
+
+.nav-section[data-accent="pink"] .nav-section-body li a:hover {
+ color: var(--color-pink);
+}
+
+.nav-section[data-accent="yellow"] .nav-section-body li a:hover {
+ color: var(--color-yellow);
+}
+
+.nav-section[data-accent="purple"] .nav-section-body li a:hover {
+ color: var(--color-purple);
+}
+
+.nav-section[data-accent="red"] .nav-section-body li a:hover {
+ color: var(--color-red);
+}
.nav-section-body li a.active {
font-weight: 600;
}
-.nav-section[data-accent="cyan"] .nav-section-body li a.active { color: var(--color-cyan); }
-.nav-section[data-accent="green"] .nav-section-body li a.active { color: var(--color-green); }
-.nav-section[data-accent="pink"] .nav-section-body li a.active { color: var(--color-pink); }
-.nav-section[data-accent="yellow"] .nav-section-body li a.active { color: var(--color-yellow); }
-.nav-section[data-accent="purple"] .nav-section-body li a.active { color: var(--color-purple); }
-.nav-section[data-accent="red"] .nav-section-body li a.active { color: var(--color-red); }
+.nav-section[data-accent="cyan"] .nav-section-body li a.active {
+ color: var(--color-cyan);
+}
+
+.nav-section[data-accent="green"] .nav-section-body li a.active {
+ color: var(--color-green);
+}
+
+.nav-section[data-accent="pink"] .nav-section-body li a.active {
+ color: var(--color-pink);
+}
+
+.nav-section[data-accent="yellow"] .nav-section-body li a.active {
+ color: var(--color-yellow);
+}
+
+.nav-section[data-accent="purple"] .nav-section-body li a.active {
+ color: var(--color-purple);
+}
+
+.nav-section[data-accent="red"] .nav-section-body li a.active {
+ color: var(--color-red);
+}
.nav-section-body li.placeholder {
color: var(--color-text-muted);
diff --git a/garden/src/types/stats.ts b/garden/src/types/stats.ts
new file mode 100644
index 0000000..9053136
--- /dev/null
+++ b/garden/src/types/stats.ts
@@ -0,0 +1,13 @@
+export interface CitizenSummary {
+ id: number;
+ username: string;
+ display_name: string;
+ avatar_url: string;
+}
+
+export interface Stats {
+ citizens: number;
+ online: number;
+ newest_citizens: CitizenSummary[];
+ online_citizens: CitizenSummary[];
+} \ No newline at end of file
diff --git a/shrine/controllers/auth.go b/shrine/controllers/auth.go
index fc14d2a..ff81ebe 100644
--- a/shrine/controllers/auth.go
+++ b/shrine/controllers/auth.go
@@ -180,4 +180,8 @@ func LogoutController(context *fiber.Ctx) error {
func MeController(context *fiber.Ctx) error {
user := auth.GetUser(context)
return Success(context, user.ToResponse())
+}
+
+func HeartbeatController(context *fiber.Ctx) error {
+ return NoContent(context)
} \ No newline at end of file
diff --git a/shrine/controllers/responses.go b/shrine/controllers/responses.go
index b3fa824..0e9e515 100644
--- a/shrine/controllers/responses.go
+++ b/shrine/controllers/responses.go
@@ -50,3 +50,7 @@ func Success(context *fiber.Ctx, data any) error {
func Created(context *fiber.Ctx, data any) error {
return shortcuts.Response(context, data).As(fiber.StatusCreated)
}
+
+func NoContent(context *fiber.Ctx) error {
+ return shortcuts.Response(context, nil).As(fiber.StatusNoContent)
+}
diff --git a/shrine/controllers/stats.go b/shrine/controllers/stats.go
new file mode 100644
index 0000000..33aa991
--- /dev/null
+++ b/shrine/controllers/stats.go
@@ -0,0 +1,30 @@
+package controllers
+
+import (
+ "shrine/repositories"
+ "shrine/types"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func StatsController(context *fiber.Ctx) error {
+ newest := repositories.NewestCitizens(5)
+ online := repositories.OnlineCitizens(10)
+
+ newestSummaries := make([]types.CitizenSummary, len(newest))
+ for i, u := range newest {
+ newestSummaries[i] = u.ToSummary()
+ }
+
+ onlineSummaries := make([]types.CitizenSummary, len(online))
+ for i, u := range online {
+ onlineSummaries[i] = u.ToSummary()
+ }
+
+ return Success(context, types.StatsResponse{
+ Citizens: repositories.CountCitizens(),
+ Online: repositories.CountOnline(),
+ NewestCitizens: newestSummaries,
+ OnlineCitizens: onlineSummaries,
+ })
+} \ No newline at end of file
diff --git a/shrine/models/user.go b/shrine/models/user.go
index 96a181f..c0e1e23 100644
--- a/shrine/models/user.go
+++ b/shrine/models/user.go
@@ -122,6 +122,15 @@ func (user *User) ToResponse() types.UserResponse {
}
}
+func (user *User) ToSummary() types.CitizenSummary {
+ return types.CitizenSummary{
+ ID: user.ID,
+ Username: user.Username,
+ DisplayName: user.DisplayName,
+ AvatarURL: user.AvatarURL,
+ }
+}
+
func (user *User) BeforeCreate(tx *gorm.DB) error {
_, bypassUsername := tx.Get("bypass_username_validation")
diff --git a/shrine/repositories/user.go b/shrine/repositories/user.go
index 1161257..d47cb33 100644
--- a/shrine/repositories/user.go
+++ b/shrine/repositories/user.go
@@ -40,4 +40,32 @@ func FindUserByVerification(hash string, verificationType enums.VerificationType
var user models.User
err := database.DB.Where("verification_hash = ? AND verification_type = ? AND verification_expiry > ?", hash, verificationType, time.Now()).First(&user).Error
return &user, err
+}
+
+func UpdateLastSeen(user *models.User) {
+ database.DB.Model(user).Update("last_seen_at", user.LastSeenAt)
+}
+
+func CountCitizens() int64 {
+ var count int64
+ database.DB.Model(&models.User{}).Where("email_verified = ?", true).Count(&count)
+ return count
+}
+
+func CountOnline() int64 {
+ var count int64
+ database.DB.Model(&models.User{}).Where("last_seen_at > ?", time.Now().Add(-5*time.Minute)).Count(&count)
+ return count
+}
+
+func NewestCitizens(limit int) []models.User {
+ var users []models.User
+ database.DB.Where("email_verified = ?", true).Order("created_at desc").Limit(limit).Find(&users)
+ return users
+}
+
+func OnlineCitizens(limit int) []models.User {
+ var users []models.User
+ database.DB.Where("last_seen_at > ?", time.Now().Add(-5*time.Minute)).Order("last_seen_at desc").Limit(limit).Find(&users)
+ return users
} \ No newline at end of file
diff --git a/shrine/router/auth.go b/shrine/router/auth.go
index 5ad5fee..0c625fe 100644
--- a/shrine/router/auth.go
+++ b/shrine/router/auth.go
@@ -16,4 +16,5 @@ func init() {
urls.Path(types.POST, "/reactivate", controllers.ResendActivationController, "reactivate")
urls.Path(types.POST, "/logout", auth.RequireAuthentication(controllers.LogoutController), "logout")
urls.Path(types.GET, "/me", auth.RequireAuthentication(controllers.MeController), "me")
+ urls.Path(types.POST, "/heartbeat", auth.RequireAuthentication(controllers.HeartbeatController), "heartbeat")
} \ No newline at end of file
diff --git a/shrine/router/stats.go b/shrine/router/stats.go
new file mode 100644
index 0000000..8c165f9
--- /dev/null
+++ b/shrine/router/stats.go
@@ -0,0 +1,13 @@
+package router
+
+import (
+ "shrine/controllers"
+ "shrine/types"
+ "shrine/utils/urls"
+)
+
+func init() {
+ urls.SetNamespace("stats")
+
+ urls.Path(types.GET, "/", controllers.StatsController, "index")
+} \ No newline at end of file
diff --git a/shrine/types/response.go b/shrine/types/response.go
index 7e15ecc..f13b8dd 100644
--- a/shrine/types/response.go
+++ b/shrine/types/response.go
@@ -31,3 +31,17 @@ type AuthResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
+
+type CitizenSummary struct {
+ ID uint `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ AvatarURL string `json:"avatar_url"`
+}
+
+type StatsResponse struct {
+ Citizens int64 `json:"citizens"`
+ Online int64 `json:"online"`
+ NewestCitizens []CitizenSummary `json:"newest_citizens"`
+ OnlineCitizens []CitizenSummary `json:"online_citizens"`
+}
diff --git a/shrine/utils/auth/auth.go b/shrine/utils/auth/auth.go
index 488ba91..3199918 100644
--- a/shrine/utils/auth/auth.go
+++ b/shrine/utils/auth/auth.go
@@ -53,6 +53,12 @@ func IsAuthenticated(context *fiber.Ctx) bool {
return false
}
+ now := time.Now()
+ if token.User.LastSeenAt == nil || time.Since(*token.User.LastSeenAt) > time.Minute {
+ token.User.LastSeenAt = &now
+ repositories.UpdateLastSeen(&token.User)
+ }
+
context.Locals(userKey, &token.User)
context.Locals(tokenHashKey, tokenHash)
return true
diff --git a/shrine/utils/shortcuts/response.go b/shrine/utils/shortcuts/response.go
index e90f16b..70332ab 100644
--- a/shrine/utils/shortcuts/response.go
+++ b/shrine/utils/shortcuts/response.go
@@ -12,5 +12,8 @@ func Response(ctx *fiber.Ctx, data any) *response {
func (r *response) As(status int) error {
r.status = status
+ if r.data == nil {
+ return r.ctx.SendStatus(status)
+ }
return r.ctx.Status(status).JSON(r.data)
}