diff --git a/DorkNet.Server/Controllers/Site/PublicSiteController.cs b/DorkNet.Server/Controllers/Site/PublicSiteController.cs
index ed83a36..06a6fad 100644
--- a/DorkNet.Server/Controllers/Site/PublicSiteController.cs
+++ b/DorkNet.Server/Controllers/Site/PublicSiteController.cs
@@ -59,6 +59,18 @@ public class PublicSiteController(
var photoCount = await db.Photos
.CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null);
+ var taggedNeedle = $",{id},";
+ var photosOfPlayerCount = await db.Photos
+ .CountAsync(ph => ph.IsPublic && ph.DeletedAt == null &&
+ ("," + ph.TaggedPlayerIdsCsv + ",").Contains(taggedNeedle));
+ var friendCount = await db.Relationships
+ .Where(r => r.Status == RelationshipStatus.Friend &&
+ (r.RequesterId == id || r.TargetId == id))
+ .Select(r => r.RequesterId == id ? r.TargetId : r.RequesterId)
+ .Distinct()
+ .CountAsync();
+ var publicRoomCount = await PublicPublishedRooms()
+ .CountAsync(r => r.CreatorPlayerId == id);
return Ok(new
{
@@ -75,6 +87,10 @@ public class PublicSiteController(
createdAt = p.CreatedAt,
profileImageName = p.ProfileImageName,
photoCount,
+ photosTakenCount = photoCount,
+ photosOfPlayerCount,
+ friendCount,
+ publicRoomCount,
});
}
@@ -94,6 +110,54 @@ public class PublicSiteController(
return Ok(await EnrichPhotosAsync(rows));
}
+ /// GET /api/site/v1/players/{id}/photos/of — public photos
+ /// where this player was tagged by the camera.
+ [HttpGet("players/{id:long}/photos/of")]
+ public async Task GetPhotosOfPlayer(long id,
+ [FromQuery] int take = 24, [FromQuery] int skip = 0)
+ {
+ take = Math.Clamp(take, 1, 100);
+ skip = Math.Max(0, skip);
+ var needle = $",{id},";
+ var rows = await db.Photos
+ .Where(p => p.IsPublic && p.DeletedAt == null &&
+ ("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
+ .OrderByDescending(p => p.CreatedAt)
+ .Skip(skip).Take(take)
+ .ToListAsync();
+ return Ok(await EnrichPhotosAsync(rows));
+ }
+
+ /// GET /api/site/v1/players/{id}/rooms — public rooms
+ /// published by this player.
+ [HttpGet("players/{id:long}/rooms")]
+ public async Task GetPlayerRooms(long id,
+ [FromQuery] int take = 24, [FromQuery] int skip = 0)
+ {
+ take = Math.Clamp(take, 1, 100);
+ skip = Math.Max(0, skip);
+ var rows = await PublicPublishedRooms()
+ .Where(r => r.CreatorPlayerId == id)
+ .OrderByDescending(r => r.HotScore)
+ .ThenByDescending(r => r.UpdatedAt)
+ .Skip(skip).Take(take)
+ .Select(r => new
+ {
+ id = r.Id,
+ name = r.Name,
+ description = r.Description,
+ creatorPlayerId = r.CreatorPlayerId,
+ isDormRoom = r.IsDormRoom,
+ isAGRoom = r.IsAGRoom,
+ visitCount = r.VisitCount,
+ visitorCount = r.VisitorCount,
+ cheerCount = r.CheerCount,
+ imageName = r.ImageName,
+ })
+ .ToListAsync();
+ return Ok(rows);
+ }
+
// ── Photos ───────────────────────────────────────────────────────
/// GET /api/site/v1/feed — global public feed, newest first.
@@ -203,6 +267,12 @@ public class PublicSiteController(
profileImageName = p.ProfileImageName,
};
+ private IQueryable PublicPublishedRooms() =>
+ db.Rooms.Where(r => r.State == 0 &&
+ r.Accessibility == 1 &&
+ !r.IsDormRoom &&
+ !r.HiddenFromBrowse);
+
/// Materialise photo rows into wire DTOs with display names
/// + room names + an absolute image URL. The URL apex switches
/// based on the request host so a localhost visitor gets localhost
@@ -213,10 +283,15 @@ public class PublicSiteController(
if (list.Count == 0) return new();
var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList();
+ var taggedIds = list
+ .SelectMany(p => ParseTaggedPlayerIds(p.TaggedPlayerIdsCsv))
+ .Distinct()
+ .ToList();
+ var playerIds = uploaderIds.Concat(taggedIds).Distinct().ToList();
var roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
- var uploaders = await db.Players
- .Where(p => uploaderIds.Contains(p.Id))
+ var playersById = await db.Players
+ .Where(p => playerIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
.ToListAsync();
var rooms = await db.Rooms
@@ -228,14 +303,14 @@ public class PublicSiteController(
.Select(r => new { r.Id, r.Name })
.ToListAsync();
- var uMap = uploaders.ToDictionary(u => u.Id);
+ var pMap = playersById.ToDictionary(u => u.Id);
var rMap = rooms.ToDictionary(r => r.Id);
var cdnHost = domain.Sub("cdn");
return list.Select(p =>
{
- uMap.TryGetValue(p.UploaderPlayerId, out var u);
+ pMap.TryGetValue(p.UploaderPlayerId, out var u);
rMap.TryGetValue(p.RoomId, out var r);
return (object)new
{
@@ -249,6 +324,17 @@ public class PublicSiteController(
caption = p.Caption,
roomId = p.RoomId,
roomName = r?.Name ?? string.Empty,
+ taggedPlayers = ParseTaggedPlayerIds(p.TaggedPlayerIdsCsv)
+ .Select(id => pMap.TryGetValue(id, out var tagged) ? tagged : null)
+ .Where(tagged => tagged is not null)
+ .Select(tagged => new
+ {
+ id = tagged!.Id,
+ username = tagged.Username,
+ displayName = tagged.DisplayName,
+ profileImageName = tagged.ProfileImageName,
+ })
+ .ToArray(),
isPublic = p.IsPublic,
cheerCount = p.CheerCount,
viewCount = p.ViewCount,
@@ -257,6 +343,16 @@ public class PublicSiteController(
}).ToList();
}
+ private static List ParseTaggedPlayerIds(string? csv)
+ {
+ if (string.IsNullOrWhiteSpace(csv)) return [];
+ return csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(s => long.TryParse(s, out var id) ? id : 0)
+ .Where(id => id > 0)
+ .Distinct()
+ .ToList();
+ }
+
// ── Signup (code redemption) ─────────────────────────────────────
/// GET /api/site/v1/join/pending-devices — device ids that
diff --git a/DorkNet.Server/Services/PlayerService.cs b/DorkNet.Server/Services/PlayerService.cs
index faa37e5..8813e93 100644
--- a/DorkNet.Server/Services/PlayerService.cs
+++ b/DorkNet.Server/Services/PlayerService.cs
@@ -681,8 +681,21 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger p.Username.Contains(query) || p.DisplayName.Contains(query))
+ .Where(p => p.Id == idQuery ||
+ p.Username.ToLower().Contains(needle) ||
+ p.DisplayName.ToLower().Contains(needle))
+ .OrderBy(p =>
+ p.Id == idQuery ||
+ p.Username.ToLower() == needle ||
+ p.DisplayName.ToLower() == needle ? 0 :
+ p.Username.ToLower().StartsWith(needle) ||
+ p.DisplayName.ToLower().StartsWith(needle) ? 1 : 2)
+ .ThenBy(p => p.Username)
.Take(clamped)
.ToListAsync();
}
diff --git a/DorkNet.Server/site/src/components/PhotoCard.tsx b/DorkNet.Server/site/src/components/PhotoCard.tsx
index d58a8d2..9ec6fe3 100644
--- a/DorkNet.Server/site/src/components/PhotoCard.tsx
+++ b/DorkNet.Server/site/src/components/PhotoCard.tsx
@@ -10,6 +10,8 @@ import { num, relativeTime } from '../lib/format';
// inner anchor preserves that even on touch devices where iOS
// occasionally fires both handlers.
export function PhotoCard({ photo }: { photo: SitePhoto }) {
+ const tagged = photo.taggedPlayers ?? [];
+
return (
{photo.caption}
)}
-
+ {tagged.length > 0 && (
+
+
With
+
+ {tagged.slice(0, 3).map(p => (
+
e.stopPropagation()}
+ className="inline-flex max-w-[8rem] items-center gap-1 rounded-md bg-ink-800/70 px-1.5 py-0.5 text-ink-200 hover:text-ink-50"
+ >
+
+
{p.displayName || p.username}
+
+ ))}
+ {tagged.length > 3 &&
+{tagged.length - 3}}
+
+
+ )}
+
♥ {num(photo.cheerCount)}
- {photo.roomName
- ? ^{photo.roomName}
- : {relativeTime(photo.createdAt)}
- }
+
+ {photo.roomName && (
+ ^{photo.roomName}
+ )}
+ {photo.roomName && · }
+ {relativeTime(photo.createdAt)}
+
diff --git a/DorkNet.Server/site/src/lib/types.ts b/DorkNet.Server/site/src/lib/types.ts
index 90c0c04..fd2dddc 100644
--- a/DorkNet.Server/site/src/lib/types.ts
+++ b/DorkNet.Server/site/src/lib/types.ts
@@ -20,6 +20,17 @@ export interface SitePlayerDetail extends SitePlayerCard {
xp: number;
createdAt: string;
photoCount: number;
+ photosTakenCount: number;
+ photosOfPlayerCount: number;
+ friendCount: number;
+ publicRoomCount: number;
+}
+
+export interface SitePhotoPlayer {
+ id: number;
+ username: string;
+ displayName: string;
+ profileImageName: string | null;
}
export interface SitePhoto {
@@ -33,6 +44,7 @@ export interface SitePhoto {
caption: string;
roomId: number;
roomName: string;
+ taggedPlayers: SitePhotoPlayer[];
isPublic: boolean;
cheerCount: number;
viewCount: number;
diff --git a/DorkNet.Server/site/src/pages/PhotoDetail.tsx b/DorkNet.Server/site/src/pages/PhotoDetail.tsx
index 8e29ef6..e3573b0 100644
--- a/DorkNet.Server/site/src/pages/PhotoDetail.tsx
+++ b/DorkNet.Server/site/src/pages/PhotoDetail.tsx
@@ -63,6 +63,24 @@ export function PhotoDetail() {
{photo.caption}
)}
+ {(photo.taggedPlayers ?? []).length > 0 && (
+
+
Players in photo
+
+ {photo.taggedPlayers.map(p => (
+
+
+
{p.displayName || p.username}
+
+ ))}
+
+
+ )}
+
Cheers
diff --git a/DorkNet.Server/site/src/pages/PlayerProfile.tsx b/DorkNet.Server/site/src/pages/PlayerProfile.tsx
index c53e350..3e760f6 100644
--- a/DorkNet.Server/site/src/pages/PlayerProfile.tsx
+++ b/DorkNet.Server/site/src/pages/PlayerProfile.tsx
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { get } from '../lib/api';
-import type { SitePhoto, SitePlayerDetail } from '../lib/types';
+import type { SitePhoto, SitePlayerDetail, SiteRoom } from '../lib/types';
import { PlayerAvatar } from '../components/PlayerAvatar';
import { PhotoCard } from '../components/PhotoCard';
import { Empty } from '../components/Empty';
import { absoluteTime, num, relativeTime } from '../lib/format';
+import { RoomCard } from './Rooms';
export function PlayerProfile() {
const { id } = useParams<{ id: string }>();
const [player, setPlayer] = useState
(null);
const [photos, setPhotos] = useState(null);
+ const [taggedPhotos, setTaggedPhotos] = useState(null);
+ const [rooms, setRooms] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
@@ -18,10 +21,14 @@ export function PlayerProfile() {
setErr(null);
setPlayer(null);
setPhotos(null);
+ setTaggedPhotos(null);
+ setRooms(null);
get(`/players/${id}`)
.then(setPlayer)
.catch(e => setErr((e as Error).message));
get(`/players/${id}/photos?take=48`).then(setPhotos).catch(() => setPhotos([]));
+ get(`/players/${id}/photos/of?take=48`).then(setTaggedPhotos).catch(() => setTaggedPhotos([]));
+ get(`/players/${id}/rooms?take=24`).then(setRooms).catch(() => setRooms([]));
}, [id]);
if (err) return (
@@ -59,13 +66,20 @@ export function PlayerProfile() {
-
+
+
+
+
+
+
+
+
- Photos
+ Photos taken
{photos === null
? Loading…
: photos.length === 0
@@ -75,6 +89,30 @@ export function PlayerProfile() {
}
+
+
+ Pictures they appear in
+ {taggedPhotos === null
+ ? Loading…
+ : taggedPhotos.length === 0
+ ?
+ :
+ {taggedPhotos.map(p =>
)}
+
+ }
+
+
+
+ Public rooms
+ {rooms === null
+ ? Loading…
+ : rooms.length === 0
+ ?
+ :
+ }
+
);
}
diff --git a/DorkNet.Server/site/src/pages/Players.tsx b/DorkNet.Server/site/src/pages/Players.tsx
index 12a59e4..b2bffee 100644
--- a/DorkNet.Server/site/src/pages/Players.tsx
+++ b/DorkNet.Server/site/src/pages/Players.tsx
@@ -33,6 +33,11 @@ export function Players() {
};
}, [setParams]);
+ useEffect(() => {
+ const next = params.get('q') ?? '';
+ setQ(current => current === next ? current : next);
+ }, [params]);
+
useEffect(() => { debounce(q); }, [q, debounce]);
return (
diff --git a/DorkNet.Server/site/src/pages/Rooms.tsx b/DorkNet.Server/site/src/pages/Rooms.tsx
index 1feccc1..a6941a0 100644
--- a/DorkNet.Server/site/src/pages/Rooms.tsx
+++ b/DorkNet.Server/site/src/pages/Rooms.tsx
@@ -55,7 +55,7 @@ export function Rooms() {
);
}
-function RoomCard({ room }: { room: SiteRoom }) {
+export function RoomCard({ room }: { room: SiteRoom }) {
const img = room.imageName ? `https://img.${imageApex()}/${encodeURIComponent(room.imageName)}?width=320&sig=p1` : null;
return (