diff options
| author | Bobby <[email protected]> | 2026-03-06 12:12:17 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-06 12:12:17 +0530 |
| commit | b947bd40d50ddc566ee859a20304655f116c9bf8 (patch) | |
| tree | 8fa08e697cd73e5eaa57291e8ae4ca9cbcdac946 | |
| parent | 994f18d65e4605502342b434acebef25589bb062 (diff) | |
| download | pagoda-b947bd40d50ddc566ee859a20304655f116c9bf8.tar.xz pagoda-b947bd40d50ddc566ee859a20304655f116c9bf8.zip | |
feat: implement user statistics and heartbeat functionality with UI updates
| -rw-r--r-- | garden/src/components/Layout.tsx | 70 | ||||
| -rw-r--r-- | garden/src/pages/home.tsx | 3 | ||||
| -rw-r--r-- | garden/src/store/auth.ts | 9 | ||||
| -rw-r--r-- | garden/src/store/stats.ts | 14 | ||||
| -rw-r--r-- | garden/src/styles/layout.css | 215 | ||||
| -rw-r--r-- | garden/src/types/stats.ts | 13 | ||||
| -rw-r--r-- | shrine/controllers/auth.go | 4 | ||||
| -rw-r--r-- | shrine/controllers/responses.go | 4 | ||||
| -rw-r--r-- | shrine/controllers/stats.go | 30 | ||||
| -rw-r--r-- | shrine/models/user.go | 9 | ||||
| -rw-r--r-- | shrine/repositories/user.go | 28 | ||||
| -rw-r--r-- | shrine/router/auth.go | 1 | ||||
| -rw-r--r-- | shrine/router/stats.go | 13 | ||||
| -rw-r--r-- | shrine/types/response.go | 14 | ||||
| -rw-r--r-- | shrine/utils/auth/auth.go | 6 | ||||
| -rw-r--r-- | shrine/utils/shortcuts/response.go | 3 |
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>© {new Date().getFullYear()} Pagoda. Brought to you by <a href="https://shi.foo" target="_blank" rel="noopener noreferrer">shi.foo</a>.</p> + <p>© {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) } |
