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 + ? + :
    + {rooms.map(r =>
  • )} +
+ } +
); } 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 (