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
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user