feat: enhance player and photo functionalities with tagging, room details, and improved search

This commit is contained in:
Alexa
2026-06-07 10:58:10 +03:00
parent feccd439d4
commit 64746cdaa2
8 changed files with 220 additions and 14 deletions
@@ -59,6 +59,18 @@ public class PublicSiteController(
var photoCount = await db.Photos var photoCount = await db.Photos
.CountAsync(ph => ph.UploaderPlayerId == id && ph.IsPublic && ph.DeletedAt == null); .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 return Ok(new
{ {
@@ -75,6 +87,10 @@ public class PublicSiteController(
createdAt = p.CreatedAt, createdAt = p.CreatedAt,
profileImageName = p.ProfileImageName, profileImageName = p.ProfileImageName,
photoCount, photoCount,
photosTakenCount = photoCount,
photosOfPlayerCount,
friendCount,
publicRoomCount,
}); });
} }
@@ -94,6 +110,54 @@ public class PublicSiteController(
return Ok(await EnrichPhotosAsync(rows)); return Ok(await EnrichPhotosAsync(rows));
} }
/// <summary>GET /api/site/v1/players/{id}/photos/of — public photos
/// where this player was tagged by the camera.</summary>
[HttpGet("players/{id:long}/photos/of")]
public async Task<IActionResult> 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));
}
/// <summary>GET /api/site/v1/players/{id}/rooms — public rooms
/// published by this player.</summary>
[HttpGet("players/{id:long}/rooms")]
public async Task<IActionResult> 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 ─────────────────────────────────────────────────────── // ── Photos ───────────────────────────────────────────────────────
/// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary> /// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary>
@@ -203,6 +267,12 @@ public class PublicSiteController(
profileImageName = p.ProfileImageName, profileImageName = p.ProfileImageName,
}; };
private IQueryable<RoomEntity> PublicPublishedRooms() =>
db.Rooms.Where(r => r.State == 0 &&
r.Accessibility == 1 &&
!r.IsDormRoom &&
!r.HiddenFromBrowse);
/// <summary>Materialise photo rows into wire DTOs with display names /// <summary>Materialise photo rows into wire DTOs with display names
/// + room names + an absolute image URL. The URL apex switches /// + room names + an absolute image URL. The URL apex switches
/// based on the request host so a localhost visitor gets localhost /// based on the request host so a localhost visitor gets localhost
@@ -213,10 +283,15 @@ public class PublicSiteController(
if (list.Count == 0) return new(); if (list.Count == 0) return new();
var uploaderIds = list.Select(p => p.UploaderPlayerId).Distinct().ToList(); 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 roomIds = list.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
var uploaders = await db.Players var playersById = await db.Players
.Where(p => uploaderIds.Contains(p.Id)) .Where(p => playerIds.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName }) .Select(p => new { p.Id, p.Username, p.DisplayName, p.ProfileImageName })
.ToListAsync(); .ToListAsync();
var rooms = await db.Rooms var rooms = await db.Rooms
@@ -228,14 +303,14 @@ public class PublicSiteController(
.Select(r => new { r.Id, r.Name }) .Select(r => new { r.Id, r.Name })
.ToListAsync(); .ToListAsync();
var uMap = uploaders.ToDictionary(u => u.Id); var pMap = playersById.ToDictionary(u => u.Id);
var rMap = rooms.ToDictionary(r => r.Id); var rMap = rooms.ToDictionary(r => r.Id);
var cdnHost = domain.Sub("cdn"); var cdnHost = domain.Sub("cdn");
return list.Select(p => 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); rMap.TryGetValue(p.RoomId, out var r);
return (object)new return (object)new
{ {
@@ -249,6 +324,17 @@ public class PublicSiteController(
caption = p.Caption, caption = p.Caption,
roomId = p.RoomId, roomId = p.RoomId,
roomName = r?.Name ?? string.Empty, 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, isPublic = p.IsPublic,
cheerCount = p.CheerCount, cheerCount = p.CheerCount,
viewCount = p.ViewCount, viewCount = p.ViewCount,
@@ -257,6 +343,16 @@ public class PublicSiteController(
}).ToList(); }).ToList();
} }
private static List<long> 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) ───────────────────────────────────── // ── Signup (code redemption) ─────────────────────────────────────
/// <summary>GET /api/site/v1/join/pending-devices — device ids that /// <summary>GET /api/site/v1/join/pending-devices — device ids that
+14 -1
View File
@@ -681,8 +681,21 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
// request `take=1000000` and exhaust the connection. 50 is enough // request `take=1000000` and exhaust the connection. 50 is enough
// for the watch's typeahead UI (it shows ~10 rows at a time). // for the watch's typeahead UI (it shows ~10 rows at a time).
var clamped = Math.Clamp(take, 1, 50); var clamped = Math.Clamp(take, 1, 50);
var needle = (query ?? string.Empty).Trim().ToLower();
if (needle.Length == 0) return [];
var idQuery = long.TryParse(needle, out var parsedId) ? parsedId : -1;
return await db.Players return await db.Players
.Where(p => 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) .Take(clamped)
.ToListAsync(); .ToListAsync();
} }
@@ -10,6 +10,8 @@ import { num, relativeTime } from '../lib/format';
// inner anchor preserves that even on touch devices where iOS // inner anchor preserves that even on touch devices where iOS
// occasionally fires both handlers. // occasionally fires both handlers.
export function PhotoCard({ photo }: { photo: SitePhoto }) { export function PhotoCard({ photo }: { photo: SitePhoto }) {
const tagged = photo.taggedPlayers ?? [];
return ( return (
<Link <Link
to={`/photo/${photo.id}`} to={`/photo/${photo.id}`}
@@ -42,12 +44,34 @@ export function PhotoCard({ photo }: { photo: SitePhoto }) {
{photo.caption && ( {photo.caption && (
<p className="mt-2 text-xs text-ink-300 line-clamp-2">{photo.caption}</p> <p className="mt-2 text-xs text-ink-300 line-clamp-2">{photo.caption}</p>
)} )}
<div className="mt-2 flex items-center justify-between text-[11px] text-ink-400"> {tagged.length > 0 && (
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-ink-400">
<span className="shrink-0 text-ink-500">With</span>
<div className="min-w-0 flex flex-wrap items-center gap-1">
{tagged.slice(0, 3).map(p => (
<Link
key={p.id}
to={`/players/${p.id}`}
onClick={e => 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"
>
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={16} />
<span className="truncate">{p.displayName || p.username}</span>
</Link>
))}
{tagged.length > 3 && <span className="text-ink-500">+{tagged.length - 3}</span>}
</div>
</div>
)}
<div className="mt-2 flex items-center justify-between gap-2 text-[11px] text-ink-400">
<span title="cheers">♥ {num(photo.cheerCount)}</span> <span title="cheers">♥ {num(photo.cheerCount)}</span>
{photo.roomName <span className="min-w-0 truncate text-right">
? <span className="font-mono truncate ml-2" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span> {photo.roomName && (
: <span>{relativeTime(photo.createdAt)}</span> <span className="font-mono text-ink-300" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
} )}
{photo.roomName && <span className="text-ink-600"> · </span>}
<span>{relativeTime(photo.createdAt)}</span>
</span>
</div> </div>
</div> </div>
</Link> </Link>
+12
View File
@@ -20,6 +20,17 @@ export interface SitePlayerDetail extends SitePlayerCard {
xp: number; xp: number;
createdAt: string; createdAt: string;
photoCount: number; 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 { export interface SitePhoto {
@@ -33,6 +44,7 @@ export interface SitePhoto {
caption: string; caption: string;
roomId: number; roomId: number;
roomName: string; roomName: string;
taggedPlayers: SitePhotoPlayer[];
isPublic: boolean; isPublic: boolean;
cheerCount: number; cheerCount: number;
viewCount: number; viewCount: number;
@@ -63,6 +63,24 @@ export function PhotoDetail() {
<p className="text-sm text-ink-200 whitespace-pre-wrap">{photo.caption}</p> <p className="text-sm text-ink-200 whitespace-pre-wrap">{photo.caption}</p>
)} )}
{(photo.taggedPlayers ?? []).length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">Players in photo</div>
<div className="flex flex-wrap gap-2">
{photo.taggedPlayers.map(p => (
<Link
key={p.id}
to={`/players/${p.id}`}
className="inline-flex max-w-full items-center gap-2 rounded-md border border-ink-800 bg-ink-900/60 px-2 py-1 text-xs text-ink-200 hover:bg-ink-800/80 hover:text-ink-50"
>
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={22} />
<span className="truncate">{p.displayName || p.username}</span>
</Link>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs"> <div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-ink-800 bg-ink-900/60 px-3 py-2"> <div className="rounded-md border border-ink-800 bg-ink-900/60 px-3 py-2">
<div className="text-[10px] uppercase tracking-widest text-ink-400">Cheers</div> <div className="text-[10px] uppercase tracking-widest text-ink-400">Cheers</div>
@@ -1,16 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { get } from '../lib/api'; 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 { PlayerAvatar } from '../components/PlayerAvatar';
import { PhotoCard } from '../components/PhotoCard'; import { PhotoCard } from '../components/PhotoCard';
import { Empty } from '../components/Empty'; import { Empty } from '../components/Empty';
import { absoluteTime, num, relativeTime } from '../lib/format'; import { absoluteTime, num, relativeTime } from '../lib/format';
import { RoomCard } from './Rooms';
export function PlayerProfile() { export function PlayerProfile() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [player, setPlayer] = useState<SitePlayerDetail | null>(null); const [player, setPlayer] = useState<SitePlayerDetail | null>(null);
const [photos, setPhotos] = useState<SitePhoto[] | null>(null); const [photos, setPhotos] = useState<SitePhoto[] | null>(null);
const [taggedPhotos, setTaggedPhotos] = useState<SitePhoto[] | null>(null);
const [rooms, setRooms] = useState<SiteRoom[] | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -18,10 +21,14 @@ export function PlayerProfile() {
setErr(null); setErr(null);
setPlayer(null); setPlayer(null);
setPhotos(null); setPhotos(null);
setTaggedPhotos(null);
setRooms(null);
get<SitePlayerDetail>(`/players/${id}`) get<SitePlayerDetail>(`/players/${id}`)
.then(setPlayer) .then(setPlayer)
.catch(e => setErr((e as Error).message)); .catch(e => setErr((e as Error).message));
get<SitePhoto[]>(`/players/${id}/photos?take=48`).then(setPhotos).catch(() => setPhotos([])); get<SitePhoto[]>(`/players/${id}/photos?take=48`).then(setPhotos).catch(() => setPhotos([]));
get<SitePhoto[]>(`/players/${id}/photos/of?take=48`).then(setTaggedPhotos).catch(() => setTaggedPhotos([]));
get<SiteRoom[]>(`/players/${id}/rooms?take=24`).then(setRooms).catch(() => setRooms([]));
}, [id]); }, [id]);
if (err) return ( if (err) return (
@@ -59,13 +66,20 @@ export function PlayerProfile() {
</div> </div>
<div className="grid grid-cols-3 sm:grid-cols-1 sm:w-32 gap-2 text-xs"> <div className="grid grid-cols-3 sm:grid-cols-1 sm:w-32 gap-2 text-xs">
<Stat label="Level" value={num(player.level)} /> <Stat label="Level" value={num(player.level)} />
<Stat label="Photos" value={num(player.photoCount)} /> <Stat label="Friends" value={num(player.friendCount)} />
<Stat label="Joined" value={relativeTime(player.createdAt)} title={absoluteTime(player.createdAt)} /> <Stat label="Joined" value={relativeTime(player.createdAt)} title={absoluteTime(player.createdAt)} />
</div> </div>
</header> </header>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<Stat label="Photos Taken" value={num(player.photosTakenCount ?? player.photoCount)} />
<Stat label="Photos Of" value={num(player.photosOfPlayerCount)} />
<Stat label="Public Rooms" value={num(player.publicRoomCount)} />
<Stat label="XP" value={num(player.xp)} />
</div>
<section> <section>
<h2 className="text-lg font-semibold text-ink-50 mb-3">Photos</h2> <h2 className="text-lg font-semibold text-ink-50 mb-3">Photos taken</h2>
{photos === null {photos === null
? <div className="text-xs text-ink-400 py-6">Loading…</div> ? <div className="text-xs text-ink-400 py-6">Loading…</div>
: photos.length === 0 : photos.length === 0
@@ -75,6 +89,30 @@ export function PlayerProfile() {
</div> </div>
} }
</section> </section>
<section>
<h2 className="text-lg font-semibold text-ink-50 mb-3">Pictures they appear in</h2>
{taggedPhotos === null
? <div className="text-xs text-ink-400 py-6">Loading…</div>
: taggedPhotos.length === 0
? <Empty title="No tagged photos" blurb={`${player.displayName || player.username} hasn't been tagged in public photos yet.`} />
: <div className="photo-grid">
{taggedPhotos.map(p => <PhotoCard key={p.id} photo={p} />)}
</div>
}
</section>
<section>
<h2 className="text-lg font-semibold text-ink-50 mb-3">Public rooms</h2>
{rooms === null
? <div className="text-xs text-ink-400 py-6">Loading…</div>
: rooms.length === 0
? <Empty title="No public rooms" blurb={`${player.displayName || player.username} hasn't published any public rooms.`} />
: <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{rooms.map(r => <li key={r.id}><RoomCard room={r} /></li>)}
</ul>
}
</section>
</div> </div>
); );
} }
@@ -33,6 +33,11 @@ export function Players() {
}; };
}, [setParams]); }, [setParams]);
useEffect(() => {
const next = params.get('q') ?? '';
setQ(current => current === next ? current : next);
}, [params]);
useEffect(() => { debounce(q); }, [q, debounce]); useEffect(() => { debounce(q); }, [q, debounce]);
return ( return (
+1 -1
View File
@@ -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; const img = room.imageName ? `https://img.${imageApex()}/${encodeURIComponent(room.imageName)}?width=320&sig=p1` : null;
return ( return (
<div className="card overflow-hidden"> <div className="card overflow-hidden">