mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: enhance player and photo functionalities with tagging, room details, and improved search
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
/// <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 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET /api/site/v1/feed — global public feed, newest first.</summary>
|
||||
@@ -203,6 +267,12 @@ public class PublicSiteController(
|
||||
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
|
||||
/// + 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<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) ─────────────────────────────────────
|
||||
|
||||
/// <summary>GET /api/site/v1/join/pending-devices — device ids that
|
||||
|
||||
@@ -681,8 +681,21 @@ public class PlayerService(DorkNetDbContext db, RoomService rooms, ILogger<Playe
|
||||
// request `take=1000000` and exhaust the connection. 50 is enough
|
||||
// for the watch's typeahead UI (it shows ~10 rows at a time).
|
||||
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
|
||||
.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)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
to={`/photo/${photo.id}`}
|
||||
@@ -42,12 +44,34 @@ export function PhotoCard({ photo }: { photo: SitePhoto }) {
|
||||
{photo.caption && (
|
||||
<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>
|
||||
{photo.roomName
|
||||
? <span className="font-mono truncate ml-2" title={`Room: ${photo.roomName}`}>^{photo.roomName}</span>
|
||||
: <span>{relativeTime(photo.createdAt)}</span>
|
||||
}
|
||||
<span className="min-w-0 truncate text-right">
|
||||
{photo.roomName && (
|
||||
<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>
|
||||
</Link>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,6 +63,24 @@ export function PhotoDetail() {
|
||||
<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="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>
|
||||
|
||||
@@ -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<SitePlayerDetail | 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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -18,10 +21,14 @@ export function PlayerProfile() {
|
||||
setErr(null);
|
||||
setPlayer(null);
|
||||
setPhotos(null);
|
||||
setTaggedPhotos(null);
|
||||
setRooms(null);
|
||||
get<SitePlayerDetail>(`/players/${id}`)
|
||||
.then(setPlayer)
|
||||
.catch(e => setErr((e as Error).message));
|
||||
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]);
|
||||
|
||||
if (err) return (
|
||||
@@ -59,13 +66,20 @@ export function PlayerProfile() {
|
||||
</div>
|
||||
<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="Photos" value={num(player.photoCount)} />
|
||||
<Stat label="Friends" value={num(player.friendCount)} />
|
||||
<Stat label="Joined" value={relativeTime(player.createdAt)} title={absoluteTime(player.createdAt)} />
|
||||
</div>
|
||||
</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>
|
||||
<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
|
||||
? <div className="text-xs text-ink-400 py-6">Loading…</div>
|
||||
: photos.length === 0
|
||||
@@ -75,6 +89,30 @@ export function PlayerProfile() {
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="card overflow-hidden">
|
||||
|
||||
Reference in New Issue
Block a user