mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-12 09:18:25 +03:00
fix: many session page improvements
This commit is contained in:
+334
-134
@@ -43,7 +43,7 @@ export default function SpecificSessionPage({ reqUser, game }: UGPT) {
|
||||
);
|
||||
}
|
||||
|
||||
return <SessionPage {...{ data, game, reqUser }} />;
|
||||
return <SessionPage key={data.session.sessionID} {...{ data, game, reqUser }} />;
|
||||
}
|
||||
|
||||
function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
|
||||
@@ -132,128 +132,137 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
|
||||
`/sessions/${session.sessionID}/adjacent`,
|
||||
);
|
||||
|
||||
const sessionIndex = sessionData.index;
|
||||
|
||||
const centerContent = (
|
||||
<div className="d-flex flex-column gap-2 align-items-center justify-content-center text-center w-100 py-4 px-3">
|
||||
<span
|
||||
className="text-muted"
|
||||
style={{ fontSize: "1.1rem", letterSpacing: "0.06em", textTransform: "uppercase" }}
|
||||
>
|
||||
Session #{sessionIndex}
|
||||
</span>
|
||||
<EditableText
|
||||
as="h1"
|
||||
authorised={isAuthorised || false}
|
||||
className="enable-rfs my-0"
|
||||
initialText={session.name || ""}
|
||||
onSubmit={(name) => {
|
||||
const newSession: SessionReturns = {
|
||||
...sessionData,
|
||||
session: { ...sessionData.session, name },
|
||||
};
|
||||
setSessionData(newSession);
|
||||
updateSession(newSession);
|
||||
}}
|
||||
placeholderText={session.name || "Untitled Session"}
|
||||
/>
|
||||
|
||||
<EditableText
|
||||
authorised={isAuthorised || false}
|
||||
className="fs-5 text-muted"
|
||||
initialText={session.desc || ""}
|
||||
onSubmit={(desc) => {
|
||||
const newSession: SessionReturns = {
|
||||
...sessionData,
|
||||
session: { ...sessionData.session, desc },
|
||||
};
|
||||
setSessionData(newSession);
|
||||
updateSession(newSession);
|
||||
}}
|
||||
placeholderText={session.desc || "No Description..."}
|
||||
/>
|
||||
|
||||
{session.highlight && (
|
||||
<Badge bg="warning" style={{ lineHeight: "15px" }}>
|
||||
Highlight!
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{user.id === loggedInUser?.id && (
|
||||
<div>
|
||||
{highlight ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setHighlight(false);
|
||||
session.highlight = false;
|
||||
await APIFetchV1(
|
||||
`/sessions/${session.sessionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ highlight: false }),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
>
|
||||
<Icon type="star" /> Un-Highlight
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setHighlight(true);
|
||||
session.highlight = true;
|
||||
await APIFetchV1(
|
||||
`/sessions/${session.sessionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ highlight: true }),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline-warning"
|
||||
>
|
||||
<Icon regular type="star" /> Highlight
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Row className="justify-content-center">
|
||||
<Col xs={12}>
|
||||
<SessionNavRow
|
||||
adjacent={adjacentData ?? null}
|
||||
currentName={session.name}
|
||||
game={game}
|
||||
username={user.username}
|
||||
/>
|
||||
</Col>
|
||||
<Col className="text-center" xs={12}>
|
||||
<div className="d-flex flex-column gap-2 flex-wrap justify-content-center align-items-center">
|
||||
<EditableText
|
||||
as="h1"
|
||||
authorised={isAuthorised || false}
|
||||
className="enable-rfs"
|
||||
initialText={session.name || ""}
|
||||
onSubmit={(name) => {
|
||||
const newSession: SessionReturns = {
|
||||
...sessionData,
|
||||
session: {
|
||||
...sessionData.session,
|
||||
name,
|
||||
},
|
||||
};
|
||||
{/* Desktop: tape layout */}
|
||||
<div className="d-none d-md-block">
|
||||
<SessionNavRow
|
||||
adjacent={adjacentData ?? null}
|
||||
currentSession={session}
|
||||
sessionIndex={sessionIndex}
|
||||
username={user.username}
|
||||
>
|
||||
{centerContent}
|
||||
</SessionNavRow>
|
||||
</div>
|
||||
|
||||
setSessionData(newSession);
|
||||
|
||||
updateSession(newSession);
|
||||
}}
|
||||
placeholderText={session.name || "Untitled Session"}
|
||||
{/* Mobile: stacked layout */}
|
||||
<div className="d-md-none">
|
||||
<MobileSessionNav
|
||||
adjacent={adjacentData ?? null}
|
||||
currentSession={session}
|
||||
sessionIndex={sessionIndex}
|
||||
username={user.username}
|
||||
/>
|
||||
|
||||
<EditableText
|
||||
authorised={isAuthorised || false}
|
||||
className="fs-5"
|
||||
initialText={session.desc || ""}
|
||||
onSubmit={(desc) => {
|
||||
const newSession: SessionReturns = {
|
||||
...sessionData,
|
||||
session: {
|
||||
...sessionData.session,
|
||||
desc,
|
||||
},
|
||||
};
|
||||
|
||||
setSessionData(newSession);
|
||||
|
||||
updateSession(newSession);
|
||||
}}
|
||||
placeholderText={session.desc || "No Description..."}
|
||||
{centerContent}
|
||||
<MobileSessionNav
|
||||
adjacent={adjacentData ?? null}
|
||||
bottom
|
||||
currentSession={session}
|
||||
sessionIndex={sessionIndex}
|
||||
username={user.username}
|
||||
/>
|
||||
</div>
|
||||
{session.highlight && (
|
||||
<Badge
|
||||
bg="warning"
|
||||
className="ms-2"
|
||||
style={{
|
||||
lineHeight: "15px",
|
||||
}}
|
||||
>
|
||||
Highlight!
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Divider className="mt-8 mb-4" />
|
||||
{user.id === loggedInUser?.id && (
|
||||
<>
|
||||
{highlight ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setHighlight(false);
|
||||
session.highlight = false;
|
||||
|
||||
await APIFetchV1(
|
||||
`/sessions/${session.sessionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
highlight: false,
|
||||
}),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
variant="outline-danger"
|
||||
>
|
||||
<Icon type="star" /> Un-Highlight Session
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setHighlight(true);
|
||||
session.highlight = true;
|
||||
|
||||
await APIFetchV1(
|
||||
`/sessions/${session.sessionID}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
highlight: true,
|
||||
}),
|
||||
},
|
||||
true,
|
||||
true,
|
||||
);
|
||||
}}
|
||||
variant="outline-warning"
|
||||
>
|
||||
<Icon regular type="star" /> Highlight Session
|
||||
</Button>
|
||||
)}
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<Divider className="mt-4 mb-4" />
|
||||
</Col>
|
||||
<SessionOverview
|
||||
reqUser={data.user}
|
||||
@@ -273,43 +282,234 @@ function SessionPage({ data, game }: { data: SessionReturns } & UGPT) {
|
||||
);
|
||||
}
|
||||
|
||||
function SessionNavRow({
|
||||
function formatSessionTimeDiff(fromSession: SessionDocument, toSession: SessionDocument): string {
|
||||
const diffMs = toSession.timeStarted - fromSession.timeStarted;
|
||||
const absDiff = Math.abs(diffMs);
|
||||
const direction = diffMs < 0 ? "before" : "after";
|
||||
|
||||
const minutes = Math.round(absDiff / 60_000);
|
||||
const hours = Math.round(absDiff / 3_600_000);
|
||||
const days = Math.round(absDiff / 86_400_000);
|
||||
const weeks = Math.round(absDiff / (7 * 86_400_000));
|
||||
const months = Math.round(absDiff / (30 * 86_400_000));
|
||||
|
||||
let amount: string;
|
||||
if (minutes < 60) {
|
||||
amount = `${minutes}m`;
|
||||
} else if (hours < 24) {
|
||||
amount = `${hours}h`;
|
||||
} else if (days < 14) {
|
||||
amount = `${days}d`;
|
||||
} else if (weeks < 8) {
|
||||
amount = `${weeks}w`;
|
||||
} else {
|
||||
amount = `${months}mo`;
|
||||
}
|
||||
|
||||
return `${amount} ${direction}`;
|
||||
}
|
||||
|
||||
function MobileSessionNav({
|
||||
adjacent,
|
||||
currentName,
|
||||
game,
|
||||
bottom,
|
||||
currentSession,
|
||||
sessionIndex,
|
||||
username,
|
||||
}: {
|
||||
adjacent: SessionAdjacentReturns | null;
|
||||
currentName: string;
|
||||
game: string;
|
||||
bottom?: boolean;
|
||||
currentSession: SessionDocument;
|
||||
sessionIndex: number;
|
||||
username: string;
|
||||
}) {
|
||||
const sessionUrl = (s: SessionDocument) =>
|
||||
`/u/${username}/games/${s.game}/sessions/${s.sessionID}`;
|
||||
|
||||
const prev = adjacent?.prev ?? null;
|
||||
const next = adjacent?.next ?? null;
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-between mb-4">
|
||||
<div style={{ minWidth: "140px" }}>
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
{!bottom && prev ? (
|
||||
<LinkButton
|
||||
className="flex-fill d-flex align-items-center gap-2 text-start py-2 px-3"
|
||||
to={sessionUrl(prev)}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
<span style={{ fontSize: "1.1rem" }}>«</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: "0.7rem", letterSpacing: "0.05em", textTransform: "uppercase", opacity: 0.6 }}>
|
||||
« #{sessionIndex - 1} · {formatSessionTimeDiff(currentSession, prev)}
|
||||
</div>
|
||||
<div className="text-truncate" style={{ fontWeight: 600 }}>
|
||||
{prev.name}
|
||||
</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
) : !bottom ? (
|
||||
<div className="flex-fill py-2 px-3 border border-secondary rounded d-flex align-items-center gap-2" style={{ opacity: 0.25 }}>
|
||||
<span style={{ fontSize: "1.1rem" }}>«</span>
|
||||
<div style={{ fontSize: "0.85rem" }}>No earlier session</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bottom && next ? (
|
||||
<LinkButton
|
||||
className="flex-fill d-flex align-items-center justify-content-end gap-2 text-end py-2 px-3"
|
||||
to={sessionUrl(next)}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: "0.7rem", letterSpacing: "0.05em", textTransform: "uppercase", opacity: 0.6 }}>
|
||||
#{sessionIndex + 1} · {formatSessionTimeDiff(currentSession, next)} »
|
||||
</div>
|
||||
<div className="text-truncate" style={{ fontWeight: 600 }}>
|
||||
{next.name}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "1.1rem" }}>»</span>
|
||||
</LinkButton>
|
||||
) : bottom ? (
|
||||
<div className="flex-fill py-2 px-3 border border-secondary rounded d-flex align-items-center justify-content-end gap-2" style={{ opacity: 0.25 }}>
|
||||
<div style={{ fontSize: "0.85rem" }}>No later session</div>
|
||||
<span style={{ fontSize: "1.1rem" }}>»</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionNavRow({
|
||||
adjacent,
|
||||
children,
|
||||
currentSession,
|
||||
sessionIndex,
|
||||
username,
|
||||
}: {
|
||||
adjacent: SessionAdjacentReturns | null;
|
||||
children: React.ReactNode;
|
||||
currentSession: SessionDocument;
|
||||
sessionIndex: number;
|
||||
username: string;
|
||||
}) {
|
||||
const sessionUrl = (s: SessionDocument) =>
|
||||
`/u/${username}/games/${s.game}/sessions/${s.sessionID}`;
|
||||
|
||||
const borderColor = "var(--bs-secondary)";
|
||||
const sideStyle = {
|
||||
border: `1px solid ${borderColor}`,
|
||||
background: "rgba(255,255,255,0.02)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex align-items-stretch" style={{ minHeight: "80px" }}>
|
||||
{/* Prev */}
|
||||
<div style={{ flex: "2 1 0", minWidth: 0 }}>
|
||||
{adjacent?.prev ? (
|
||||
<LinkButton to={sessionUrl(adjacent.prev)} variant="outline-secondary">
|
||||
« Prev
|
||||
<LinkButton
|
||||
className="w-100 h-100 d-flex flex-column align-items-start justify-content-center px-3 py-3 text-start"
|
||||
style={{ borderRadius: "6px 0 0 6px", ...sideStyle }}
|
||||
to={sessionUrl(adjacent.prev)}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
« #{sessionIndex - 1} ·{" "}
|
||||
{formatSessionTimeDiff(currentSession, adjacent.prev)}
|
||||
</span>
|
||||
<span
|
||||
className="text-truncate w-100 mt-1"
|
||||
style={{ fontSize: "1rem", fontWeight: 600 }}
|
||||
>
|
||||
{adjacent.prev.name}
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button disabled variant="outline-secondary">
|
||||
« Prev
|
||||
</Button>
|
||||
<div
|
||||
className="w-100 h-100 d-flex flex-column align-items-start justify-content-center px-3 py-3"
|
||||
style={{ borderRadius: "6px 0 0 6px", ...sideStyle, opacity: 0.25 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
« Prev
|
||||
</span>
|
||||
<span className="mt-1" style={{ fontSize: "1rem", fontWeight: 600 }}>
|
||||
(This is your first session!)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted text-truncate px-2">{currentName}</span>
|
||||
<div style={{ minWidth: "140px" }} className="text-end">
|
||||
|
||||
{/* Current session — children */}
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
flex: "3 1 0",
|
||||
minWidth: 0,
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
borderBottom: `1px solid ${borderColor}`,
|
||||
background: "rgba(255,255,255,0.015)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Next */}
|
||||
<div style={{ flex: "2 1 0", minWidth: 0 }}>
|
||||
{adjacent?.next ? (
|
||||
<LinkButton to={sessionUrl(adjacent.next)} variant="outline-secondary">
|
||||
Next »
|
||||
<LinkButton
|
||||
className="w-100 h-100 d-flex flex-column align-items-end justify-content-center px-3 py-3 text-end"
|
||||
style={{ borderRadius: "0 6px 6px 0", ...sideStyle }}
|
||||
to={sessionUrl(adjacent.next)}
|
||||
variant="outline-secondary"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
>
|
||||
{formatSessionTimeDiff(currentSession, adjacent.next)} ·
|
||||
#{sessionIndex + 1} »
|
||||
</span>
|
||||
<span
|
||||
className="text-truncate w-100 mt-1"
|
||||
style={{ fontSize: "1rem", fontWeight: 600 }}
|
||||
>
|
||||
{adjacent.next.name}
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button disabled variant="outline-secondary">
|
||||
Next »
|
||||
</Button>
|
||||
<div
|
||||
className="w-100 h-100 d-flex flex-column align-items-end justify-content-center px-3 py-3"
|
||||
style={{ borderRadius: "0 6px 6px 0", ...sideStyle, opacity: 0.25 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
Next »
|
||||
</span>
|
||||
<span className="mt-1" style={{ fontSize: "1rem", fontWeight: 600 }}>
|
||||
(This is your most recent session!)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface SessionReturns<GPT extends V3Game = V3Game> {
|
||||
songs: SongDocument<GameGroup>[];
|
||||
charts: ChartDocument<GPT>[];
|
||||
user: UserDocument;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SessionAdjacentReturns {
|
||||
|
||||
@@ -125,12 +125,9 @@ describe("CreatePBDoc", () => {
|
||||
} as ScoreData<"iidx-sp">,
|
||||
});
|
||||
|
||||
const pb = (await CreatePBDoc(
|
||||
"iidx-sp",
|
||||
userId,
|
||||
Testing511SPA,
|
||||
log,
|
||||
)) as PBScoreDocumentNoRank<"iidx-sp"> | undefined;
|
||||
const pb = (await CreatePBDoc("iidx-sp", userId, Testing511SPA, log)) as
|
||||
| PBScoreDocumentNoRank<"iidx-sp">
|
||||
| undefined;
|
||||
|
||||
expect(pb).toBeDefined();
|
||||
expect(pb!.scoreData.score).toBe(1926);
|
||||
@@ -171,12 +168,9 @@ describe("CreatePBDoc", () => {
|
||||
} as ScoreData<"iidx-sp">,
|
||||
});
|
||||
|
||||
const pb = (await CreatePBDoc(
|
||||
"iidx-sp",
|
||||
userId,
|
||||
Testing511SPA,
|
||||
log,
|
||||
)) as PBScoreDocumentNoRank<"iidx-sp"> | undefined;
|
||||
const pb = (await CreatePBDoc("iidx-sp", userId, Testing511SPA, log)) as
|
||||
| PBScoreDocumentNoRank<"iidx-sp">
|
||||
| undefined;
|
||||
|
||||
expect(pb).toBeDefined();
|
||||
expect(pb!.scoreData.score).toBe(2040);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GetSessionFolderRaises } from "#lib/folders/get-session-folder-raises";
|
||||
import { withSession, withSessionOwner } from "#lib/router/middleware";
|
||||
import { success } from "#lib/router/typed-router";
|
||||
import { API_V1_ROUTER } from "#server/router/api/v1/router";
|
||||
import { GetAdjacentSessions, GetSessionData } from "#utils/queries/sessions";
|
||||
import { GetAdjacentSessions, GetSessionData, GetSessionIndex } from "#utils/queries/sessions";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
@@ -15,10 +15,14 @@ import { ExpectedErr } from "bliss";
|
||||
*/
|
||||
API_V1_ROUTER.add("GET /sessions/:sessionID", withSession, async ({ ctx }) => {
|
||||
const { sessionDoc: session } = ctx;
|
||||
const sessionData = await GetSessionData(session);
|
||||
const [sessionData, index] = await Promise.all([
|
||||
GetSessionData(session),
|
||||
GetSessionIndex(session),
|
||||
]);
|
||||
|
||||
return success(`Successfully returned session ${session.name}.`, {
|
||||
charts: sessionData.charts,
|
||||
index,
|
||||
scoreInfo: sessionData.scoreInfo,
|
||||
scores: sessionData.scores,
|
||||
session,
|
||||
|
||||
@@ -97,6 +97,31 @@ export async function GetAdjacentSessions(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based chronological index of this session among all sessions
|
||||
* for the same user and game, using (time_ended, id) as the sort key.
|
||||
*/
|
||||
export async function GetSessionIndex(session: SessionDocument): Promise<number> {
|
||||
const timeEnded = UnixMillisecondsToISO8601(session.timeEnded);
|
||||
|
||||
const row = await DB.selectFrom("session")
|
||||
.select(DB.fn.countAll<number>().as("count"))
|
||||
.where("session.user_id", "=", session.userID)
|
||||
.where("session.game", "=", session.game)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("session.time_ended", "<", timeEnded),
|
||||
eb.and([
|
||||
eb("session.time_ended", "=", timeEnded),
|
||||
eb("session.id", "<=", session.sessionID),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return Number(row.count);
|
||||
}
|
||||
|
||||
export async function GetSessionData(session: SessionDocument): Promise<{
|
||||
charts: Array<ChartDocument>;
|
||||
scoreInfo: Array<SessionScoreInfo>;
|
||||
|
||||
Reference in New Issue
Block a user