dasdatmeespresso

This commit is contained in:
zk
2026-05-16 19:53:23 +00:00
parent 8a125634a8
commit acea12ec8a
73 changed files with 5248 additions and 2090 deletions
@@ -0,0 +1,220 @@
/**
* MyProposalsPage — shows the logged-in user's own quest proposals,
* with edit and withdraw capabilities.
*/
import type { UnsuccessfulAPIResponse } from "tachi-common";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import LoadingWrapper from "#components/util/LoadingWrapper";
import { UserContext } from "#context/UserContext";
import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useCallback, useContext, useEffect, useState } from "react";
import { Alert, Badge, Button, Card, Col, Modal, Row } from "react-bootstrap";
import { Link, Redirect } from "react-router-dom";
type MyProposal = {
createdAt: string;
prNumber: number;
proposalID: string;
prUrl: string;
rawQuestlines: unknown;
rawQuests: unknown;
status: string;
updatedAt: string;
};
type MyProposalsResponse = {
proposals: Array<MyProposal>;
};
function statusBadge(status: string) {
switch (status) {
case "open":
return <Badge bg="success">Open</Badge>;
case "merged":
return <Badge bg="primary">Merged</Badge>;
case "closed":
return <Badge bg="secondary">Closed</Badge>;
default:
return <Badge bg="secondary">{status}</Badge>;
}
}
export default function MyProposalsPage() {
useSetSubheader(["My Quest Proposals"]);
const { user } = useContext(UserContext);
if (!user) {
return <Redirect to="/login" />;
}
const [data, setData] = useState<MyProposalsResponse | null>(null);
const [error, setError] = useState<UnsuccessfulAPIResponse | null>(null);
const reload = useCallback(() => {
setData(null);
setError(null);
APIFetchV1<MyProposalsResponse>("/proposals/mine").then((res) => {
if (!res.success) {
setError(res);
return;
}
setData(res.body);
});
}, []);
useEffect(() => {
reload();
}, [reload]);
return (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<h2 className="mb-0">My Quest Proposals</h2>
{TachiConfig.QUEST_PROPOSALS_ENABLED && (
<Link className="btn btn-outline-success btn-sm" to="/quests">
+ New Proposal
</Link>
)}
</div>
<LoadingWrapper dataset={data} error={error}>
<>
{data?.proposals.length === 0 ? (
<p className="text-body-secondary">
You haven&apos;t submitted any proposals yet.
{TachiConfig.QUEST_PROPOSALS_ENABLED && (
<>
{" "}
<Link to="/quests">Create one in the Quest Editor!</Link>
</>
)}
</p>
) : (
<Row className="g-3">
{data?.proposals.map((proposal) => (
<Col key={proposal.proposalID} lg={6} xs={12}>
<MyProposalCard onWithdrawn={reload} proposal={proposal} />
</Col>
))}
</Row>
)}
</>
</LoadingWrapper>
</>
);
}
function MyProposalCard({
proposal,
onWithdrawn,
}: {
onWithdrawn: () => void;
proposal: MyProposal;
}) {
const [withdrawing, setWithdrawing] = useState(false);
const [withdrawErr, setWithdrawErr] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const questNames = Array.isArray(proposal.rawQuests)
? (proposal.rawQuests as Array<{ name: string }>).map((q) => q.name).join(", ")
: "Quest Proposal";
const handleWithdraw = async () => {
setWithdrawErr(null);
setWithdrawing(true);
try {
const res = await APIFetchV1(`/proposals/${proposal.proposalID}`, {
method: "DELETE",
});
if (!res.success) {
setWithdrawErr(res.description);
return;
}
setShowConfirm(false);
onWithdrawn();
} catch (e) {
setWithdrawErr((e as Error).message);
} finally {
setWithdrawing(false);
}
};
return (
<>
<Card className="h-100">
<Card.Body>
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-body-secondary small">PR #{proposal.prNumber}</span>
{statusBadge(proposal.status)}
</div>
<h6 className="mb-1">{questNames}</h6>
<p className="text-body-secondary small mb-2">
Submitted {new Date(proposal.createdAt).toLocaleDateString()}
{proposal.updatedAt !== proposal.createdAt && (
<>
{" · "}Updated {new Date(proposal.updatedAt).toLocaleDateString()}
</>
)}
</p>
<div className="d-flex gap-2 flex-wrap">
<a
className="btn btn-outline-primary btn-sm"
href={proposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
View on GitHub
</a>
{proposal.status === "open" && (
<Button
onClick={() => setShowConfirm(true)}
size="sm"
variant="outline-danger"
>
Withdraw
</Button>
)}
</div>
</Card.Body>
</Card>
<Modal onHide={() => setShowConfirm(false)} show={showConfirm}>
<Modal.Header closeButton>
<Modal.Title>Withdraw Proposal?</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
This will close PR #{proposal.prNumber} and mark the proposal as withdrawn.
This cannot be undone.
</p>
{withdrawErr && <Alert variant="danger">{withdrawErr}</Alert>}
</Modal.Body>
<Modal.Footer>
<Button
disabled={withdrawing}
onClick={() => setShowConfirm(false)}
variant="secondary"
>
Cancel
</Button>
<Button disabled={withdrawing} onClick={handleWithdraw} variant="danger">
{withdrawing ? "Withdrawing…" : "Withdraw"}
</Button>
</Modal.Footer>
</Modal>
</>
);
}
@@ -0,0 +1,153 @@
/**
* ProposalsPage — public listing of all open quest proposals.
*/
import type { UnsuccessfulAPIResponse } from "tachi-common";
import useSetSubheader from "#components/layout/header/useSetSubheader";
import LoadingWrapper from "#components/util/LoadingWrapper";
import { TachiConfig } from "#lib/config";
import { APIFetchV1 } from "#util/api";
import React, { useEffect, useState } from "react";
import { Badge, Card, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
type ProposalSummary = {
createdAt: string;
prNumber: number;
proposalID: string;
prUrl: string;
quests: Array<{ game: string; name: string }>;
status: string;
submitterUsername: string;
};
type ProposalsResponse = {
page: number;
proposals: Array<ProposalSummary>;
};
function statusBadge(status: string) {
switch (status) {
case "open":
return <Badge bg="success">Open</Badge>;
case "merged":
return <Badge bg="primary">Merged</Badge>;
case "closed":
return <Badge bg="secondary">Closed</Badge>;
default:
return <Badge bg="secondary">{status}</Badge>;
}
}
export default function ProposalsPage() {
useSetSubheader(["Community", "Quest Proposals"]);
const [data, setData] = useState<ProposalsResponse | null>(null);
const [error, setError] = useState<UnsuccessfulAPIResponse | null>(null);
const [page, setPage] = useState(0);
useEffect(() => {
setData(null);
setError(null);
APIFetchV1<ProposalsResponse>(`/proposals?page=${page}`).then((res) => {
if (!res.success) {
setError(res);
return;
}
setData(res.body);
});
}, [page]);
return (
<>
<h2 className="mb-1">Community Quest Proposals</h2>
<p className="text-body-secondary small mb-3">
Quest proposals submitted by the community for review.
{TachiConfig.QUEST_PROPOSALS_ENABLED ? (
<>
{" "}
Open a PR to add your own quests via the <Link to="/quests">Quest Editor</Link>.
</>
) : (
<> Open a PR on the seeds repository to add your own quests.</>
)}
</p>
<LoadingWrapper dataset={data} error={error}>
<>
{data?.proposals.length === 0 && page === 0 ? (
<p className="text-body-secondary">No open proposals yet. Be the first!</p>
) : (
<Row className="g-3">
{data?.proposals.map((p) => (
<Col key={p.proposalID} lg={4} md={6} xs={12}>
<ProposalCard proposal={p} />
</Col>
))}
</Row>
)}
<div className="d-flex gap-2 mt-4">
{page > 0 && (
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setPage((prev) => prev - 1)}
type="button"
>
← Previous
</button>
)}
{data && data.proposals.length === 20 && (
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => setPage((prev) => prev + 1)}
type="button"
>
Next →
</button>
)}
</div>
</>
</LoadingWrapper>
</>
);
}
function ProposalCard({ proposal }: { proposal: ProposalSummary }) {
return (
<Card className="h-100">
<Card.Body>
<div className="d-flex align-items-center justify-content-between mb-2">
<span className="text-body-secondary small">PR #{proposal.prNumber}</span>
{statusBadge(proposal.status)}
</div>
<h6 className="mb-1">{proposal.quests.map((q) => q.name).join(", ")}</h6>
<p className="text-body-secondary small mb-2">
{proposal.quests.map((q) => q.game).join(", ")}
</p>
<p className="text-body-secondary small mb-3">
Submitted by{" "}
<Link to={`/u/${proposal.submitterUsername}`}>
{proposal.submitterUsername}
</Link>{" "}
on {new Date(proposal.createdAt).toLocaleDateString()}
</p>
<a
className="btn btn-outline-primary btn-sm"
href={proposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
View PR on GitHub
</a>
</Card.Body>
</Card>
);
}
@@ -164,7 +164,7 @@ export default function UserOrphansPage({ reqUser }: { reqUser: UserDocument })
<p className="mb-2">
When an import cannot match a song or chart (SongOrChartNotFound), Tachi still
stores that datapoint as an <strong>orphan</strong>. Orphans are retried
automatically around <strong>00:01 UTC</strong> each day, or you can run a full
automatically around <strong>1 AM UTC</strong> each day, or you can run a full
reprocess below.
</p>
<p className="mb-0">
@@ -1,5 +1,5 @@
import GoalBuilder from "#components/targets/GoalBuilder";
import DeleteGoalsModal from "#components/targets/DeleteGoalsModal";
import GoalBuilder from "#components/targets/GoalBuilder";
import GoalSubInfo from "#components/targets/GoalSubInfo";
import ApiError from "#components/util/ApiError";
import Divider from "#components/util/Divider";
@@ -14,7 +14,7 @@ import { CreateGoalSubDataset } from "#util/data";
import React, { useContext, useReducer, useState } from "react";
import { Button, Col, Modal } from "react-bootstrap";
import { Link } from "react-router-dom";
import { type GoalDocument, FormatGame } from "tachi-common";
import { FormatGame, type GoalDocument } from "tachi-common";
export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
const [showAdd, setShowAdd] = useState(false);
@@ -40,7 +40,9 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
const userMap = new Map([[reqUser.id, reqUser]]);
const dataset = CreateGoalSubDataset(data, userMap);
const handleAddGoal = async (rawGoal: { goal: Pick<GoalDocument, "charts" | "criteria" | "name"> }) => {
const handleAddGoal = async (rawGoal: {
goal: Pick<GoalDocument, "charts" | "criteria" | "name">;
}) => {
await APIFetchV1(
`/users/${reqUser.id}/games/${game}/targets/goals/add-goal`,
{
@@ -120,11 +122,7 @@ export default function UGPTGoalsPage({ reqUser, game }: UGPT) {
<Modal.Title>Add New Goal</Modal.Title>
</Modal.Header>
<Modal.Body>
<GoalBuilder
confirmLabel="Add Goal"
game={game}
onCreate={handleAddGoal}
/>
<GoalBuilder confirmLabel="Add Goal" game={game} onCreate={handleAddGoal} />
</Modal.Body>
</Modal>
@@ -15,17 +15,19 @@ import EditableQuest from "#components/targets/quests/editor/EditableQuest";
import Divider from "#components/util/Divider";
import EditableText from "#components/util/EditableText";
import Icon from "#components/util/Icon";
import Muted from "#components/util/Muted";
import { UserContext } from "#context/UserContext";
import { TachiConfig } from "#lib/config";
import { type RawQuestDocument, type RawQuestlineDocument } from "#types/tachi";
import { APIFetchV1 } from "#util/api";
import { ChangeAtPosition, DeleteInPosition } from "#util/misc";
import { p, type PrudenceSchema } from "prudence";
import React, { useEffect, useMemo, useState } from "react";
import { Alert, Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Alert, Badge, Button, Col, Form, Modal, Row, Spinner } from "react-bootstrap";
import {
ALL_GAMES,
FormatGame,
type GameGroup,
FormatPrError,
type GameGroup,
GetGameGroupConfig,
LEGACY_GameGroupPTToGame,
type LEGACY_GPTString,
@@ -43,16 +45,8 @@ const LOCAL_QUESTLINE_KEY = "LOCAL_QUESTLINES";
const PR_LOCAL_QUESTS_SCHEMA: PrudenceSchema = {
json: [
{
game: p.isIn(TachiConfig.GAME_GROUPS),
playtype: (self, parent) => {
const gameConfig = GetGameGroupConfig(parent.game as GameGroup);
if (!(gameConfig.playtypes as ReadonlyArray<unknown>).includes(self)) {
return `Invalid playtype '${String(self)}' for ${String(parent.game)}`;
}
return true;
},
/** V3 game id (e.g. `iidx-sp`), matching {@link RawQuestDocument}. */
game: p.isIn(ALL_GAMES),
name: "string",
desc: "string",
rawQuestData: [
@@ -140,8 +134,25 @@ function downloadJson(data: unknown, filename: string) {
// ─── Main component ───────────────────────────────────────────────────────────
// ─── Proposal types (mirrors server API response) ────────────────────────────
type MyProposal = {
createdAt: string;
prNumber: number;
proposalID: string;
prUrl: string;
rawQuestlines: Array<RawQuestlineDocument>;
rawQuests: Array<RawQuestDocument>;
status: string;
updatedAt: string;
};
// ─── Main component ───────────────────────────────────────────────────────────
export default function QuestEditor() {
useSetSubheader(["Developer Utils", "Quest & Questline Editor"]);
useSetSubheader(["Quest Editor"]);
const { user } = useContext(UserContext);
const initQuests = useMemo(() => loadLocalQuests(), []);
const initQuestlines = useMemo(() => loadLocalQuestlines(), []);
@@ -152,6 +163,35 @@ export default function QuestEditor() {
const [showImportQuests, setShowImportQuests] = useState(false);
const [showImportQuestlines, setShowImportQuestlines] = useState(false);
const [showSubmitModal, setShowSubmitModal] = useState(false);
// When non-null, we're updating an existing proposal rather than creating one
const [editingProposal, setEditingProposal] = useState<MyProposal | null>(null);
// My proposals list
const [proposals, setProposals] = useState<Array<MyProposal> | null>(null);
const [proposalsLoading, setProposalsLoading] = useState(false);
// ── Fetch my proposals ─────────────────────────────────────────────────────
const fetchProposals = useCallback(() => {
if (!user) {
return;
}
setProposalsLoading(true);
APIFetchV1<{ proposals: Array<MyProposal> }>("/proposals/mine").then((res) => {
setProposalsLoading(false);
if (res.success) {
setProposals(res.body.proposals);
}
});
}, [user]);
useEffect(() => {
fetchProposals();
}, [fetchProposals]);
// ── Persist to localStorage on every change ────────────────────────────────
useEffect(() => {
@@ -163,7 +203,7 @@ export default function QuestEditor() {
}, [questlines]);
// ── Derived ────────────────────────────────────────────────────────────────
const selectedQuest = selectedQuestIdx !== null ? quests[selectedQuestIdx] ?? null : null;
const selectedQuest = selectedQuestIdx !== null ? (quests[selectedQuestIdx] ?? null) : null;
const addQuest = (gptString: LEGACY_GPTString) => {
const [game, playtype] = gptString.split(":") as [GameGroup, LEGACY_Playtype];
@@ -183,14 +223,81 @@ export default function QuestEditor() {
});
};
const loadProposalIntoEditor = (proposal: MyProposal) => {
if (quests.length > 0 || questlines.length > 0) {
if (
!confirm(
"This will replace your current editor contents with the proposal's quests. Continue?",
)
) {
return;
}
}
setQuests(proposal.rawQuests);
setQuestlines(proposal.rawQuestlines);
setSelectedQuestIdx(null);
setEditingProposal(proposal);
};
const clearEditingProposal = () => {
setEditingProposal(null);
};
const handleAfterSubmit = () => {
setEditingProposal(null);
fetchProposals();
};
return (
<Row className="g-3">
<Col xs={12}>
<h2 className="mb-1">Quest &amp; Questline Editor</h2>
<p className="text-body-secondary small">
Build quests and questlines locally, then download the JSON files and submit
them as a PR to be included on the site.
</p>
<div className="d-flex align-items-start justify-content-between flex-wrap gap-2">
<div>
<h2 className="mb-1">Quest &amp; Questline Editor</h2>
<p className="text-body-secondary small mb-0">
Build quests locally and submit them to the community.
</p>
</div>
{user && quests.length > 0 && (
user.canSubmitQuests ? (
<Button onClick={() => setShowSubmitModal(true)} variant="success">
<Icon type="code-branch" />{" "}
{editingProposal
? `Update PR #${editingProposal.prNumber}`
: "Submit to Community"}
</Button>
) : (
<span className="text-body-secondary small text-end" style={{ maxWidth: "260px" }}>
<Icon type="lock" />{" "}
Want to submit quests to the community? Ask an admin to grant you
quest-submitter access.
</span>
)
)}
</div>
{/* Editing-proposal banner */}
{editingProposal && (
<Alert className="mt-3 mb-0 d-flex align-items-center justify-content-between" variant="info">
<span>
<Icon type="pencil" />{" "}
Editing{" "}
<a
href={editingProposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
PR #{editingProposal.prNumber}
</a>
{" — "}make your changes, then click <strong>Update PR</strong>.
</span>
<Button onClick={clearEditingProposal} size="sm" variant="outline-light">
Stop editing
</Button>
</Alert>
)}
<Divider />
</Col>
@@ -233,6 +340,7 @@ export default function QuestEditor() {
if (confirm("Delete all quests and start over?")) {
setQuests([]);
setSelectedQuestIdx(null);
setEditingProposal(null);
}
}}
size="sm"
@@ -276,10 +384,10 @@ export default function QuestEditor() {
<QuestlineComposer
onAddQuestline={(ql) => setQuestlines((prev) => [...prev, ql])}
onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))}
onUpdate={(updated, idx) =>
setQuestlines(ChangeAtPosition(questlines, updated, idx))
}
onDelete={(idx) => setQuestlines(DeleteInPosition(questlines, idx))}
questlines={questlines}
quests={quests}
/>
@@ -321,6 +429,20 @@ export default function QuestEditor() {
</div>
</Col>
{/* ── My Proposals panel ────────────────────────────────────────── */}
{user && (
<Col xs={12}>
<Divider />
<MyProposalsPanel
editingProposalID={editingProposal?.proposalID ?? null}
loading={proposalsLoading}
onLoad={loadProposalIntoEditor}
onWithdrawn={fetchProposals}
proposals={proposals}
/>
</Col>
)}
{/* Import modals */}
{showImportQuests && (
<ImportJsonModal
@@ -340,10 +462,179 @@ export default function QuestEditor() {
onImport={(data) => setQuestlines(data as RawQuestlineDocument[])}
/>
)}
{showSubmitModal && (
<SubmitProposalModal
editingProposal={editingProposal}
onAfterSubmit={handleAfterSubmit}
onHide={() => setShowSubmitModal(false)}
questlines={questlines}
quests={quests}
/>
)}
</Row>
);
}
// ─── MyProposalsPanel ─────────────────────────────────────────────────────────
function statusBadge(status: string) {
switch (status) {
case "open":
return <Badge bg="success">Open</Badge>;
case "merged":
return <Badge bg="primary">Merged</Badge>;
case "closed":
return <Badge bg="secondary">Closed</Badge>;
default:
return <Badge bg="secondary">{status}</Badge>;
}
}
function MyProposalsPanel({
proposals,
loading,
editingProposalID,
onLoad,
onWithdrawn,
}: {
editingProposalID: string | null;
loading: boolean;
onLoad: (p: MyProposal) => void;
onWithdrawn: () => void;
proposals: Array<MyProposal> | null;
}) {
const [withdrawingID, setWithdrawingID] = useState<string | null>(null);
const handleWithdraw = async (proposal: MyProposal) => {
if (!confirm(`Withdraw PR #${proposal.prNumber}? This will close it on GitHub.`)) {
return;
}
setWithdrawingID(proposal.proposalID);
try {
const res = await APIFetchV1(`/proposals/${proposal.proposalID}`, {
method: "DELETE",
});
if (!res.success) {
alert(`Failed to withdraw: ${res.description}`);
} else {
onWithdrawn();
}
} finally {
setWithdrawingID(null);
}
};
return (
<div>
<div className="d-flex align-items-center justify-content-between mb-2">
<h5 className="mb-0">My Proposals</h5>
{loading && <Spinner animation="border" size="sm" />}
</div>
{proposals === null && !loading && (
<p className="text-body-secondary small">
Could not load proposals — are proposals enabled on this instance?
</p>
)}
{proposals !== null && proposals.length === 0 && (
<p className="text-body-secondary small">
No proposals yet. Build some quests and click{" "}
<strong>Submit to Community</strong>!
</p>
)}
{proposals !== null && proposals.length > 0 && (
<div className="d-flex flex-wrap gap-3">
{proposals.map((proposal) => {
const questNames = proposal.rawQuests.map((q) => q.name).join(", ");
const isEditing = editingProposalID === proposal.proposalID;
return (
<div
className={`border rounded p-3 ${isEditing ? "border-info" : ""}`}
key={proposal.proposalID}
style={{ minWidth: "260px", maxWidth: "340px" }}
>
<div className="d-flex align-items-start justify-content-between mb-1">
<span className="text-body-secondary small">
PR #{proposal.prNumber}
</span>
{statusBadge(proposal.status)}
</div>
<p className="mb-1 fw-semibold" style={{ fontSize: "0.9rem" }}>
{questNames}
</p>
<p className="text-body-secondary mb-2" style={{ fontSize: "0.75rem" }}>
{proposal.rawQuests[0]
? FormatGame(proposal.rawQuests[0].game as V3Game)
: ""}{" "}
· {new Date(proposal.createdAt).toLocaleDateString()}
{proposal.updatedAt !== proposal.createdAt && (
<>
{" · "}updated{" "}
{new Date(proposal.updatedAt).toLocaleDateString()}
</>
)}
</p>
<div className="d-flex gap-2 flex-wrap">
<a
className="btn btn-outline-secondary btn-sm py-0"
href={proposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
GitHub
</a>
{proposal.status === "open" && (
<>
{isEditing ? (
<span className="btn btn-info btn-sm py-0 disabled">
<Icon type="pencil" /> Editing…
</span>
) : (
<Button
onClick={() => onLoad(proposal)}
size="sm"
style={{ padding: "0 0.5rem" }}
variant="outline-primary"
>
<Icon type="pencil" /> Edit
</Button>
)}
<Button
disabled={withdrawingID === proposal.proposalID}
onClick={() => handleWithdraw(proposal)}
size="sm"
style={{ padding: "0 0.5rem" }}
variant="outline-danger"
>
{withdrawingID === proposal.proposalID ? (
<Spinner animation="border" size="sm" />
) : (
"Withdraw"
)}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── QuestList ────────────────────────────────────────────────────────────────
function QuestList({
@@ -359,16 +650,15 @@ function QuestList({
}) {
const [gpt, setGpt] = useState<LEGACY_GPTString | null>(null);
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap(
(gameGroup) => {
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> =
TachiConfig.GAME_GROUPS.flatMap((gameGroup) => {
const config = GetGameGroupConfig(gameGroup);
return config.playtypes.map((pt) => ({
value: `${gameGroup}:${pt}` as LEGACY_GPTString,
label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)),
}));
},
);
});
return (
<div className="d-flex flex-column gap-1 mb-3">
@@ -378,18 +668,14 @@ function QuestList({
{quests.map((quest, i) => (
<button
className={`btn btn-sm text-start text-truncate ${
selectedIdx === i
? "btn-primary"
: "btn-outline-secondary"
selectedIdx === i ? "btn-primary" : "btn-outline-secondary"
}`}
key={i}
onClick={() => onSelect(i)}
title={quest.name}
type="button"
>
<span className="small me-2 text-body-secondary">
{FormatGame(quest.game)}
</span>
<span className="small me-2 text-body-secondary">{FormatGame(quest.game)}</span>
{quest.name}
</button>
))}
@@ -442,18 +728,17 @@ function QuestlineComposer({
quests: Array<RawQuestDocument>;
}) {
const [newName, setNewName] = useState("");
const [newGame, setNewGame] = useState<LEGACY_GPTString | "">("");
const [newGame, setNewGame] = useState<"" | LEGACY_GPTString>("");
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> = TachiConfig.GAME_GROUPS.flatMap(
(gameGroup) => {
const allGpts: Array<{ label: string; value: LEGACY_GPTString }> =
TachiConfig.GAME_GROUPS.flatMap((gameGroup) => {
const config = GetGameGroupConfig(gameGroup);
return config.playtypes.map((pt) => ({
value: `${gameGroup}:${pt}` as LEGACY_GPTString,
label: FormatGame(LEGACY_GameGroupPTToGame(gameGroup, pt)),
}));
},
);
});
return (
<div className="d-flex flex-column gap-3 mb-3">
@@ -466,8 +751,8 @@ function QuestlineComposer({
key={idx}
onDelete={() => onDelete(idx)}
onUpdate={(updated) => onUpdate(updated, idx)}
quests={quests}
questline={ql}
quests={quests}
/>
))}
@@ -507,6 +792,8 @@ function QuestlineComposer({
LEGACY_Playtype,
];
const v3Game = LEGACY_GameGroupPTToGame(gameGroup, playtype);
const slug = newName
.trim()
.toLowerCase()
@@ -517,8 +804,7 @@ function QuestlineComposer({
questlineID: `${slug}-${Date.now()}`,
name: newName.trim(),
desc: "",
game: gameGroup,
playtype,
game: v3Game,
quests: [],
});
@@ -547,9 +833,7 @@ function QuestlineCard({
questline: RawQuestlineDocument;
quests: Array<RawQuestDocument>;
}) {
const availableQuests = quests.filter(
(q) => !questline.quests.includes(q.name),
);
const availableQuests = quests.filter((q) => !questline.quests.includes(q.name));
const addQuestByName = (name: string) => {
if (!questline.quests.includes(name)) {
@@ -577,20 +861,22 @@ function QuestlineCard({
<div className="border rounded p-2">
<div className="d-flex align-items-start gap-2 mb-1">
<div className="flex-grow-1">
<EditableText
as="span"
authorised
initialText={questline.name}
onSubmit={(name) => onUpdate({ ...questline, name })}
placeholderText="Questline name"
/>
<EditableText
as="span"
authorised
initialText={questline.name}
onSubmit={(name) => onUpdate({ ...questline, name })}
placeholderText="Questline name"
/>
<EditableText
authorised
initialText={questline.desc}
onSubmit={(desc) => onUpdate({ ...questline, desc })}
placeholderText="Description…"
/>
<span className="text-body-secondary small">{questline.game} / {questline.playtype}</span>
<span className="text-body-secondary small">
{questline.game}
</span>
</div>
<button
className="btn btn-outline-danger btn-sm py-0"
@@ -665,6 +951,194 @@ function QuestlineCard({
);
}
// ─── SubmitProposalModal ──────────────────────────────────────────────────────
type SubmitResult = {
prNumber: number;
proposalID: string;
prUrl: string;
status: string;
};
function SubmitProposalModal({
editingProposal,
onAfterSubmit,
onHide,
questlines,
quests,
}: {
editingProposal: MyProposal | null;
onAfterSubmit: () => void;
onHide: () => void;
questlines: Array<RawQuestlineDocument>;
quests: Array<RawQuestDocument>;
}) {
const isUpdate = editingProposal !== null;
const [prTitle, setPrTitle] = useState("");
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<SubmitResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const handleSubmit = async () => {
setErr(null);
setSubmitting(true);
try {
const res = isUpdate
? await APIFetchV1<SubmitResult>(`/proposals/${editingProposal.proposalID}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quests,
questlines: questlines.length > 0 ? questlines : undefined,
}),
})
: await APIFetchV1<SubmitResult>("/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quests,
questlines: questlines.length > 0 ? questlines : undefined,
prTitle: prTitle.trim() || undefined,
}),
});
if (!res.success) {
setErr(res.description);
return;
}
setResult(res.body);
onAfterSubmit();
} catch (e) {
setErr((e as Error).message);
} finally {
setSubmitting(false);
}
};
return (
<Modal onHide={onHide} show>
<Modal.Header closeButton>
<Modal.Title>
{isUpdate ? `Update PR #${editingProposal.prNumber}` : "Submit to Community"}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{result ? (
<div>
<Alert variant="success">
{isUpdate ? (
<>
<strong>PR updated!</strong> A new commit has been pushed to{" "}
<a href={result.prUrl} rel="noopener noreferrer" target="_blank">
PR #{result.prNumber}
</a>
.
</>
) : (
<>
<strong>PR opened!</strong> Your quests have been submitted for
review.
</>
)}
</Alert>
<a
className="btn btn-outline-primary btn-sm"
href={result.prUrl}
rel="noopener noreferrer"
target="_blank"
>
<Icon type="code-branch" /> View on GitHub
</a>
</div>
) : (
<>
{isUpdate ? (
<p className="text-body-secondary small">
This will push a new commit to{" "}
<a
href={editingProposal.prUrl}
rel="noopener noreferrer"
target="_blank"
>
PR #{editingProposal.prNumber}
</a>{" "}
with your updated quests.
</p>
) : (
<p className="text-body-secondary small">
This will open a pull request on GitHub with your quests. A
reviewer will check the content before it&apos;s merged into the
site.
</p>
)}
<h6>Quests ({quests.length})</h6>
<ul className="small">
{quests.map((q, i) => (
<li key={i}>
<strong>{q.name}</strong>{" "}
<span className="text-body-secondary">({q.game})</span>
</li>
))}
</ul>
{questlines.length > 0 && (
<>
<h6>Questlines ({questlines.length})</h6>
<ul className="small">
{questlines.map((ql, i) => (
<li key={i}>
<strong>{ql.name}</strong>
</li>
))}
</ul>
</>
)}
{!isUpdate && (
<Form.Group className="mt-3">
<Form.Label>PR title (optional)</Form.Label>
<Form.Control
maxLength={200}
onChange={(e) => setPrTitle(e.target.value)}
placeholder={`Add quest: ${quests.map((q) => q.name).join(", ")}`}
type="text"
value={prTitle}
/>
</Form.Group>
)}
{err && (
<Alert className="mt-3" variant="danger">
{err}
</Alert>
)}
</>
)}
</Modal.Body>
{!result && (
<Modal.Footer>
<Button disabled={submitting} onClick={onHide} variant="secondary">
Cancel
</Button>
<Button disabled={submitting} onClick={handleSubmit} variant="success">
{submitting
? isUpdate
? "Updating…"
: "Submitting…"
: isUpdate
? "Update PR"
: "Submit PR"}
</Button>
</Modal.Footer>
)}
</Modal>
);
}
// ─── ImportJsonModal ──────────────────────────────────────────────────────────
function ImportJsonModal({
@@ -1,13 +0,0 @@
import { useEffect } from "react";
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
export default function SeedsViewer() {
useEffect(() => {
if (SEEDS_URL) {
window.location.replace(SEEDS_URL);
}
}, []);
return null;
}
@@ -1,3 +1,5 @@
import MyProposalsPage from "#app/pages/dashboard/proposals/MyProposalsPage";
import ProposalsPage from "#app/pages/dashboard/proposals/ProposalsPage";
import SearchPage from "#app/pages/dashboard/search/SearchPage";
import NotificationsPage from "#app/pages/dashboard/users/NotificationsPage";
import { ErrorPage } from "#app/pages/ErrorPage";
@@ -8,11 +10,13 @@ import DashboardErrorBoundary from "#components/util/DashboardErrorBoundary";
import { BackgroundContext } from "#context/BackgroundContext";
import { BannedContext } from "#context/BannedContext";
import { UserContext } from "#context/UserContext";
import { TachiConfig } from "#lib/config";
import { APIFetchV1, ToAPIURL } from "#util/api";
import React, { useContext, useEffect, useState } from "react";
import { Redirect, Route, Switch } from "react-router-dom";
import { DashboardPage } from "../pages/dashboard/DashboardPage";
import QuestEditor from "../pages/dashboard/utils/QuestEditor";
import AdminRoutes from "./AdminRoutes";
import GameRoutes from "./GameRoutes";
import ImportRoutes from "./ImportRoutes";
@@ -20,6 +24,14 @@ import { RedirectLegacyUserRoutes } from "./RedirectLegacyRoutes";
import UserRoutes from "./UserRoutes";
import UtilRoutes from "./UtilRoutes";
function QuestEditorRoute() {
if (!TachiConfig.QUEST_PROPOSALS_ENABLED) {
return <Redirect to="/" />;
}
return <QuestEditor />;
}
export default function DashboardRoutes() {
const { user } = useContext(UserContext);
const { banned } = useContext(BannedContext);
@@ -116,10 +128,30 @@ export default function DashboardRoutes() {
<ImportRoutes />
</Route>
<Route exact path="/utils/seeds">
<Redirect to="/" />
</Route>
<Route exact path="/utils/quests">
<Redirect to="/quests" />
</Route>
<Route exact path="/quests">
<QuestEditorRoute />
</Route>
<Route path="/utils">
<UtilRoutes />
</Route>
<Route exact path="/proposals">
<ProposalsPage />
</Route>
<Route exact path="/proposals/mine">
<MyProposalsPage />
</Route>
<Route path="/notifications">
<NotificationsPage />
</Route>
@@ -1,23 +1,13 @@
import ImportAnalysers from "#app/pages/dashboard/utils/ImportAnalysers";
import QuestEditor from "#app/pages/dashboard/utils/QuestEditor";
import SeedsViewer from "#app/pages/dashboard/utils/SeedsViewer";
import React from "react";
import { Route, Switch } from "react-router-dom";
export default function UtilRoutes() {
return (
<Switch>
<Route exact path="/utils/seeds">
<SeedsViewer />
</Route>
<Route path="/utils/imports">
<ImportAnalysers />
</Route>
<Route exact path="/utils/quests">
<QuestEditor />
</Route>
</Switch>
);
}
@@ -6,6 +6,7 @@ import ApiError from "#components/util/ApiError";
import Divider from "#components/util/Divider";
import Loading from "#components/util/Loading";
import useApiQuery from "#components/util/query/useApiQuery";
import { TachiConfig } from "#lib/config";
import { type GamePT } from "#types/react";
import { CreateGoalMap } from "#util/data";
import React from "react";
@@ -48,9 +49,16 @@ function QuestlineSelector({ game }: GamePT) {
<Row>
<Col xs={12}>
<div className="w-100 text-center">
Looks like this game has no quests. If you want, you could{" "}
<Link to="/utils/quests">create your own</Link>, and submit them in the
discord!
Looks like this game has no quests.
{TachiConfig.QUEST_PROPOSALS_ENABLED ? (
<>
{" "}
If you want, you could <Link to="/quests">create your own</Link>, and
submit them in the discord!
</>
) : (
<> You can suggest new quests in the discord!</>
)}
</div>
</Col>
</Row>
@@ -130,7 +130,7 @@ export default function ImportInfo({
are matched with data, all we have to display might be a hash.
<br />
<strong>SongOrChartNotFound</strong> means the score was still{" "}
<strong>saved as an orphan</strong> for nightly matching (around 00:01
<strong>saved as an orphan</strong> for nightly matching (around 1 AM
UTC) or manual reprocess - see{" "}
{user ? (
<Link to={`/u/${user.username}/orphans`}>Orphan scores</Link>
@@ -2,15 +2,16 @@ import useApiQuery from "#components/util/query/useApiQuery";
import { AllLUGPTStatsContext } from "#context/AllLUGPTStatsContext";
import { UserContext } from "#context/UserContext";
import { UserSettingsContext } from "#context/UserSettingsContext";
import { TachiConfig } from "#lib/config";
import { type SetState } from "#types/react";
import React, { useContext, useEffect } from "react";
import Nav from "react-bootstrap/Nav";
import { NavLink } from "react-router-dom";
import { type UserGameStats } from "tachi-common";
import GlobalInfoDropdown from "./GlobalInfoDropdown";
import ImportScoresDropdown from "./ImportScoresDropdown";
import UGPTDropdown from "./UGPTDropdown";
import UtilsDropdown from "./UtilsDropdown";
const toggleClassNames = "w-100 justify-content-between";
const menuClassNames = "shadow-none shadow-lg-lg";
@@ -71,13 +72,25 @@ export function HeaderMenu({
style={dropdownMenuStyle}
/>
)}
{TachiConfig.QUEST_PROPOSALS_ENABLED && (
<NavLink
activeClassName="active"
className="h-14 d-flex align-items-center fw-semibold px-2 rounded text-decoration-none focus-visible-ring nav-link"
onClick={() => setState?.(false)}
to="/quests"
>
Quest Editor
</NavLink>
)}
{settings?.preferences.developerMode && (
<UtilsDropdown
className={toggleClassNames}
menuClassName={menuClassNames}
setState={setState}
style={dropdownMenuStyle}
/>
<NavLink
activeClassName="active"
className="h-14 d-flex align-items-center fw-semibold px-2 rounded text-decoration-none focus-visible-ring nav-link"
onClick={() => setState?.(false)}
to="/utils/imports"
>
Import Management
</NavLink>
)}
</Nav>
);
@@ -1,57 +0,0 @@
import DropdownNavLink from "#components/ui/DropdownNavLink";
import QuickDropdown from "#components/ui/QuickDropdown";
import { type SetState } from "#types/react";
import React from "react";
import DropdownItem from "react-bootstrap/DropdownItem";
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) {
return (
<>
<DropdownItem
className="rounded focus-visible-ring"
href={SEEDS_URL}
onClick={onClick}
rel="noopener noreferrer"
target="_blank"
>
Seeds Management
</DropdownItem>
<DropdownNavLink onClick={onClick} to="/utils/imports">
Import Management
</DropdownNavLink>
<DropdownNavLink onClick={onClick} to="/utils/quests">
Quest Creator
</DropdownNavLink>
</>
);
}
export default function UtilsDropdown({
className,
menuClassName,
style,
setState,
}: {
className?: string;
menuClassName?: string;
setState?: SetState<boolean>;
style?: React.CSSProperties;
}) {
const handleClick = () => {
setState?.(false);
};
return (
<QuickDropdown
caret
className={`h-14 ${className}`}
menuClassName={menuClassName}
menuStyle={style}
toggle="Developer Utils"
variant="clear"
>
<UtilsLinks onClick={handleClick} />
</QuickDropdown>
);
}
@@ -4,19 +4,14 @@ import { type CellsRenderFN, type QuestWithRelated } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import {
type GameGroup,
type GoalDocument,
LEGACY_FormatGameGroupPT,
type LEGACY_Playtypes,
} from "tachi-common";
import { FormatGame, type GoalDocument, type V3Game } from "tachi-common";
import { type Header } from "../components/TachiTable";
export const SeedsQuestsHeaders: Header<QuestWithRelated>[] = [
["ID", "ID", StrSOV((x) => x.questID)],
["Name", "Name", StrSOV((x) => x.name)],
["GPT", "GPT", StrSOV((x) => x.game)],
["Game", "Game", StrSOV((x) => x.game)],
["Goals", "Goals"],
];
@@ -24,11 +19,7 @@ export const SeedsQuestSearchFns: SearchFunctions<QuestWithRelated> = {
name: (x) => x.name,
questID: (x) => x.questID,
game: (x) => x.game,
gpt: (x) =>
LEGACY_FormatGameGroupPT(
x.game as GameGroup,
x.playtype as LEGACY_Playtypes[typeof x.game],
),
gpt: (x) => FormatGame(x.game as V3Game),
goals: (x) =>
Object.values(x.__related.goals)
.map((e) => e!.name)
@@ -48,10 +39,7 @@ export const SeedsQuestCells: CellsRenderFN<QuestWithRelated> = ({
<strong>{data.name}</strong>
</td>
<td>
{LEGACY_FormatGameGroupPT(
data.game as GameGroup,
data.playtype as LEGACY_Playtypes[typeof data.game],
)}
{FormatGame(data.game as V3Game)}
</td>
<td>
<div style={{ maxHeight: "200px", overflowY: "auto" }}>
@@ -3,14 +3,14 @@ import { type CellsRenderFN, type QuestlineWithRelated } from "#types/seeds";
import { StrSOV } from "#util/sorts";
import { type SearchFunctions } from "#util/ztable/search";
import React from "react";
import { type GameGroup, LEGACY_FormatGameGroupPT, type LEGACY_Playtypes } from "tachi-common";
import { FormatGame, type V3Game } from "tachi-common";
import { type Header } from "../components/TachiTable";
export const SeedsQuestlineHeaders: Header<QuestlineWithRelated>[] = [
["ID", "ID", StrSOV((x) => x.questlineID)],
["Name", "Name", StrSOV((x) => x.name)],
["GPT", "GPT", StrSOV((x) => x.game)],
["Game", "Game", StrSOV((x) => x.game)],
["Quests", "Quests"],
];
@@ -19,11 +19,7 @@ export const SeedsQuestlineSearchFns: SearchFunctions<QuestlineWithRelated> = {
questlineID: (x) => x.questlineID,
desc: (x) => x.desc,
game: (x) => x.game,
gpt: (x) =>
LEGACY_FormatGameGroupPT(
x.game as GameGroup,
x.playtype as LEGACY_Playtypes[typeof x.game],
),
gpt: (x) => FormatGame(x.game as V3Game),
quests: (x) =>
Object.values(x.__related.quests)
.filter((e) => e !== undefined)
@@ -42,10 +38,7 @@ export const SeedsQuestlineCells: CellsRenderFN<QuestlineWithRelated> = ({ data
<Muted>{data.desc}</Muted>
</td>
<td>
{LEGACY_FormatGameGroupPT(
data.game as GameGroup,
data.playtype as LEGACY_Playtypes[typeof data.game],
)}
{FormatGame(data.game as V3Game)}
</td>
<td className="text-start">
<div style={{ maxHeight: "200px", overflowY: "auto" }}>
@@ -56,12 +49,7 @@ export const SeedsQuestlineCells: CellsRenderFN<QuestlineWithRelated> = ({ data
<div key={e}>
{quest ? (
<span>
{quest.name} (
{LEGACY_FormatGameGroupPT(
quest.game as GameGroup,
quest.playtype as LEGACY_Playtypes[typeof quest.game],
)}
)
{quest.name} ({FormatGame(quest.game as V3Game)})
</span>
) : (
<span className="text-danger">UNKNOWN QUEST {e}</span>
@@ -377,7 +377,11 @@ function CriteriaPicker({
game={game}
onChange={(mode, countNum) => {
if (mode === "single") {
setCriteria({ mode: "single", key: criteria.key, value: criteria.value });
setCriteria({
mode: "single",
key: criteria.key,
value: criteria.value,
});
} else {
setCriteria({ ...criteria, countNum: countNum ?? 0, mode });
}
@@ -409,7 +413,11 @@ function CriteriaValuePicker({
switch (conf.type) {
case "NULLABLE_GRAPH":
case "GRAPH":
return <span className="text-body-secondary small">Cannot set goals for graph metrics.</span>;
return (
<span className="text-body-secondary small">
Cannot set goals for graph metrics.
</span>
);
case "DECIMAL":
case "INTEGER":
@@ -457,9 +465,7 @@ function CriteriaModePicker({
criteria: GoalDocument["criteria"];
onChange: (mode: GoalDocument["criteria"]["mode"], countNum?: number) => void;
} & GamePT) {
const [absCount, setAbsCount] = useState(
criteria.mode === "absolute" ? criteria.countNum : 10,
);
const [absCount, setAbsCount] = useState(criteria.mode === "absolute" ? criteria.countNum : 10);
const [perCount, setPerCount] = useState(
criteria.mode === "proportion" ? criteria.countNum * 100 : 10,
);
@@ -635,7 +641,9 @@ function ChartSelect({
loadOptions={loadOptions}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(data: any) =>
onChange(Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value)
onChange(
Array.isArray(data) ? data.map((e: { value: string }) => e.value) : data.value,
)
}
placeholder="Search for a chart..."
/>
@@ -0,0 +1,69 @@
.track {
background: color-mix(in srgb, var(--bs-body-color) 9%, var(--bs-tertiary-bg));
border: 1px solid color-mix(in srgb, var(--bs-border-color) 70%, transparent);
border-radius: 9999px;
box-shadow:
inset 0 1px 2px rgb(0 0 0 / 0.06),
0 1px 0 rgb(255 255 255 / 0.04);
height: 0.7rem;
overflow: hidden;
position: relative;
}
[data-bs-theme="dark"] .track,
[data-bs-theme="oled"] .track {
box-shadow: inset 0 1px 3px rgb(0 0 0 / 0.35);
}
.fill {
position: relative;
background: linear-gradient(
105deg,
color-mix(in srgb, var(--bs-primary) 72%, var(--bs-body-bg)),
var(--bs-primary) 45%,
color-mix(in srgb, var(--bs-primary) 78%, white)
);
border-radius: inherit;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.22),
0 0 0 1px color-mix(in srgb, var(--bs-primary) 25%, transparent);
height: 100%;
min-width: 0;
transition: width 0.55s cubic-bezier(0.33, 1, 0.68, 1);
}
.fill::after {
background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.22), transparent);
content: "";
height: 100%;
left: 0;
opacity: 0.85;
pointer-events: none;
position: absolute;
top: 0;
transform: translateX(-100%);
width: 40%;
}
@media (prefers-reduced-motion: no-preference) {
.fillActive::after {
animation: questBarShimmer 2.4s ease-in-out infinite;
}
}
@keyframes questBarShimmer {
0%,
100% {
transform: translateX(-100%);
}
50% {
transform: translateX(350%);
}
}
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
}
@@ -0,0 +1,33 @@
import React from "react";
import styles from "./QuestProgressBar.module.scss";
/** Renders a themed track + fill for quest / questline completion (0–100%). */
export default function QuestProgressBar({
percent,
"aria-label": ariaLabel,
className,
}: {
"aria-label"?: string;
className?: string;
percent: number;
}) {
const clamped = Math.min(100, Math.max(0, Number.isFinite(percent) ? percent : 0));
const active = clamped > 0 && clamped < 100;
return (
<div
aria-label={ariaLabel}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(clamped)}
className={`${styles.track} ${className ?? ""}`}
role="progressbar"
>
<div
className={`${styles.fill} ${active ? styles.fillActive : ""}`}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
@@ -1,6 +1,6 @@
import QuickTooltip from "#components/layout/misc/QuickTooltip";
import Card from "#components/layout/page/Card";
import Divider from "#components/util/Divider";
import QuestProgressBar from "#components/targets/QuestProgressBar";
import Icon from "#components/util/Icon";
import LinkButton from "#components/util/LinkButton";
import Muted from "#components/util/Muted";
@@ -10,7 +10,7 @@ import { APIFetchV1 } from "#util/api";
import { GetGoalIDsFromQuest } from "#util/data";
import { FormatTime } from "#util/time";
import React, { useContext, useState } from "react";
import { Badge, Button, Collapse, ProgressBar } from "react-bootstrap";
import { Badge, Button, Collapse } from "react-bootstrap";
import { type QuestDocument, type QuestlineDocument } from "tachi-common";
export default function Questline({
@@ -35,7 +35,9 @@ export default function Questline({
<div className="d-flex align-items-start justify-content-between gap-3">
<div>
<h4 className="mb-1">{questline.name}</h4>
<Muted>{totalQuests} {totalQuests === 1 ? "Quest" : "Quests"}</Muted>
<Muted>
{totalQuests} {totalQuests === 1 ? "Quest" : "Quests"}
</Muted>
</div>
{!quests && (
<div className="flex-shrink-0">
@@ -60,18 +62,21 @@ export default function Questline({
</Badge>
) : (
<>
<div className="d-flex justify-content-between small text-body-secondary mb-1">
<span>Questline Progress</span>
<span>
{achievedQuests} / {totalQuests} quests complete
<div className="d-flex justify-content-between align-items-baseline gap-2 small text-body-secondary mb-1">
<span className="fw-medium text-body">
Questline progress
</span>
<span className="text-end text-nowrap">
<span className="fw-semibold tabular-nums text-body">
{progressPct}%
</span>
<span className="mx-1 text-body-secondary">·</span>
{achievedQuests} / {totalQuests} quests
</span>
</div>
<ProgressBar
className="rounded-pill"
now={progressPct}
striped={progressPct > 0}
style={{ height: "10px" }}
variant="primary"
<QuestProgressBar
aria-label={`Questline progress: ${progressPct} percent, ${achievedQuests} of ${totalQuests} quests complete`}
percent={progressPct}
/>
</>
)}
@@ -140,7 +145,11 @@ function QuestTimelineNode({
{!isLast && (
<div
className="flex-grow-1 mt-1"
style={{ width: "2px", background: "var(--bs-border-color)", minHeight: "16px" }}
style={{
width: "2px",
background: "var(--bs-border-color)",
minHeight: "16px",
}}
/>
)}
</div>
@@ -222,7 +231,13 @@ function QuestSubscribeButton({ quest }: { quest: QuestDocument }) {
size="sm"
variant="outline-danger"
>
{loading ? "…" : <><Icon type="trash" /> Unsubscribe</>}
{loading ? (
"…"
) : (
<>
<Icon type="trash" /> Unsubscribe
</>
)}
</Button>
</QuickTooltip>
);
@@ -245,7 +260,13 @@ function QuestSubscribeButton({ quest }: { quest: QuestDocument }) {
size="sm"
variant="outline-success"
>
{loading ? "…" : <><Icon type="scroll" /> Subscribe</>}
{loading ? (
"…"
) : (
<>
<Icon type="scroll" /> Subscribe
</>
)}
</Button>
);
}
@@ -271,9 +292,19 @@ export function InnerQuestInfo({ quest }: { quest: QuestDocument }) {
>
<div>
{questSub.achieved ? (
<Icon colour="success" regular style={{ verticalAlign: "middle" }} type="check-square" />
<Icon
colour="success"
regular
style={{ verticalAlign: "middle" }}
type="check-square"
/>
) : (
<Icon colour="danger" regular style={{ verticalAlign: "middle" }} type="square" />
<Icon
colour="danger"
regular
style={{ verticalAlign: "middle" }}
type="square"
/>
)}
</div>
</QuickTooltip>
@@ -1,5 +1,6 @@
import QuickTooltip from "#components/layout/misc/QuickTooltip";
import Card from "#components/layout/page/Card";
import QuestProgressBar from "#components/targets/QuestProgressBar";
import Divider from "#components/util/Divider";
import GoalLink from "#components/util/GoalLink";
import Icon from "#components/util/Icon";
@@ -12,7 +13,7 @@ import { GetGoalIDsFromQuest } from "#util/data";
import { HumanisedJoinArray } from "#util/misc";
import { FormatTime } from "#util/time";
import React, { useContext, useState } from "react";
import { Badge, Button, ProgressBar } from "react-bootstrap";
import { Badge, Button } from "react-bootstrap";
import {
FormatGame,
type GoalDocument,
@@ -44,8 +45,9 @@ export default function Quest({
const [show, setShow] = useState(!collapsible);
const subscribeBtn = user && (
questSub ? (
const subscribeBtn =
user &&
(questSub ? (
<Button
disabled={subscribing}
onClick={async () => {
@@ -62,7 +64,13 @@ export default function Quest({
size="sm"
variant="outline-danger"
>
{subscribing ? "Unsubscribing…" : <><Icon type="trash" /> Unsubscribe</>}
{subscribing ? (
"Unsubscribing…"
) : (
<>
<Icon type="trash" /> Unsubscribe
</>
)}
</Button>
) : (
<Button
@@ -81,10 +89,15 @@ export default function Quest({
size="sm"
variant="success"
>
{subscribing ? "Subscribing…" : <><Icon type="scroll" /> Subscribe</>}
{subscribing ? (
"Subscribing…"
) : (
<>
<Icon type="scroll" /> Subscribe
</>
)}
</Button>
)
);
));
return (
<Card
@@ -108,19 +121,19 @@ export default function Quest({
</Badge>
) : (
<>
<div className="d-flex justify-content-between small text-body-secondary mb-1">
<span>Progress</span>
<span>
<div className="d-flex justify-content-between align-items-baseline gap-2 small text-body-secondary mb-1">
<span className="fw-medium text-body">Progress</span>
<span className="text-end text-nowrap">
<span className="fw-semibold tabular-nums text-body">
{progressPct}%
</span>
<span className="mx-1 text-body-secondary">·</span>
{achievedGoals} / {totalGoals} goals
</span>
</div>
<ProgressBar
className="rounded-pill"
label={`${progressPct}%`}
now={progressPct}
striped={progressPct > 0 && progressPct < 100}
style={{ height: "12px" }}
variant={progressPct === 100 ? "success" : "primary"}
<QuestProgressBar
aria-label={`Quest progress: ${progressPct} percent, ${achievedGoals} of ${totalGoals} goals complete`}
percent={progressPct}
/>
</>
)}
@@ -179,9 +192,7 @@ function QuestSectionComponent({
</Badge>
)}
</div>
{section.desc && (
<p className="text-body-secondary small mb-2">{section.desc}</p>
)}
{section.desc && <p className="text-body-secondary small mb-2">{section.desc}</p>}
<hr className="mt-0 mb-3 opacity-10" />
{section.goals.length === 0 ? (
@@ -199,9 +210,7 @@ function QuestSectionComponent({
);
}
return (
<GoalCard game={game} goal={goal} key={i} note={goalRef.note} />
);
return <GoalCard game={game} goal={goal} key={i} note={goalRef.note} />;
})}
</div>
)}
@@ -274,7 +283,7 @@ function GoalCard({
title="Update this goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
)}
</div>
@@ -315,10 +324,7 @@ export function InnerQuestSectionGoal({
return (
<>
<div className="w-100 d-flex align-items-center gap-2">
<Icon
style={{ verticalAlign: "middle", fontSize: "0.4rem" }}
type="circle"
/>
<Icon style={{ verticalAlign: "middle", fontSize: "0.4rem" }} type="circle" />
<GoalLink goal={goal} />
{onEdit && (
<button
@@ -327,7 +333,7 @@ export function InnerQuestSectionGoal({
title="Update this goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
)}
</div>
@@ -353,11 +359,26 @@ export function InnerQuestSectionGoal({
>
<div>
{isAchieved ? (
<Icon colour="success" regular style={{ verticalAlign: "middle" }} type="check-square" />
<Icon
colour="success"
regular
style={{ verticalAlign: "middle" }}
type="check-square"
/>
) : inProgress ? (
<Icon colour="warning" regular style={{ verticalAlign: "middle" }} type="square" />
<Icon
colour="warning"
regular
style={{ verticalAlign: "middle" }}
type="square"
/>
) : (
<Icon colour="danger" regular style={{ verticalAlign: "middle" }} type="square" />
<Icon
colour="danger"
regular
style={{ verticalAlign: "middle" }}
type="square"
/>
)}
</div>
</QuickTooltip>
@@ -371,7 +392,7 @@ export function InnerQuestSectionGoal({
title="Update this goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
)}
@@ -9,7 +9,7 @@ import { type GamePT } from "#types/react";
import { type RawQuestDocument, type RawQuestGoal, type RawQuestSection } from "#types/tachi";
import { ChangeAtPosition, CopyToClipboard, DeleteInPosition } from "#util/misc";
import React, { useState } from "react";
import { Button, Collapse, Modal } from "react-bootstrap";
import { Button, Modal } from "react-bootstrap";
import { FormatGame } from "tachi-common";
export default function EditableQuest({
@@ -53,7 +53,10 @@ export default function EditableQuest({
})
}
onDelete={() =>
onChange({ ...quest, rawQuestData: DeleteInPosition(quest.rawQuestData, i) })
onChange({
...quest,
rawQuestData: DeleteInPosition(quest.rawQuestData, i),
})
}
onMoveDown={
i < quest.rawQuestData.length - 1
@@ -226,7 +229,11 @@ function QuestSection({
)}
<div className="d-flex gap-2 mt-2">
<Button onClick={() => setShowGoalBuilder(true)} size="sm" variant="outline-success">
<Button
onClick={() => setShowGoalBuilder(true)}
size="sm"
variant="outline-success"
>
<Icon type="plus" /> Add Goal
</Button>
<Button
@@ -284,7 +291,9 @@ function EditableGoalRow({
<div className="d-flex align-items-center gap-2 rounded border px-2 py-1">
<Icon style={{ fontSize: "0.4rem" }} type="circle" />
<span className="flex-grow-1 small">{rawGoal.goal.name}</span>
{rawGoal.note && <span className="text-body-secondary small fst-italic">{rawGoal.note}</span>}
{rawGoal.note && (
<span className="text-body-secondary small fst-italic">{rawGoal.note}</span>
)}
<div className="d-flex gap-1 ms-auto">
<button
@@ -311,7 +320,7 @@ function EditableGoalRow({
title="Edit goal"
type="button"
>
<Icon type="pencil" />
<Icon type="pencil-alt" />
</button>
<button
className="btn btn-outline-danger btn-sm py-0"
@@ -67,9 +67,10 @@ export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) {
</div>
<div className="col-12 col-lg-6 d-flex justify-content-center flex-column align-items-center">
<StatusComponent reqUser={reqUser} />
{loggedInUser?.authLevel === UserAuthLevels.ADMIN && (
<>
<Divider />
{loggedInUser?.authLevel === UserAuthLevels.ADMIN && (
<>
<Divider />
<div className="d-flex flex-wrap gap-2 justify-content-center">
{reqUser.isSupporter ? (
<Button
onClick={() =>
@@ -107,8 +108,47 @@ export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) {
Make Supporter?
</Button>
)}
</>
)}
{reqUser.canSubmitQuests ? (
<Button
onClick={() =>
APIFetchV1(
`/admin/quest-submitter/${reqUser.id}`,
{ method: "DELETE" },
true,
true,
).then(async (res) => {
if (res.success) {
await invalidateProfileQueries();
}
})
}
variant="warning"
>
Revoke Quest Submitter?
</Button>
) : (
<Button
onClick={() =>
APIFetchV1(
`/admin/quest-submitter/${reqUser.id}`,
{ method: "POST" },
true,
true,
).then(async (res) => {
if (res.success) {
await invalidateProfileQueries();
}
})
}
variant="outline-success"
>
Grant Quest Submitter
</Button>
)}
</div>
</>
)}
</div>
<div className="col-12 col-lg-3 d-flex justify-content-center">
<ul>
+4 -4
View File
@@ -77,12 +77,12 @@ export type RawQuestGoal = {
* authoring questlines locally in the Quest Editor before uploading as seeds.
*/
export type RawQuestlineDocument = {
desc: string;
/** V3Game identifier, e.g. `"iidx-sp"`. */
game: string;
name: string;
/** User-chosen slug / identifier (becomes `questlineID` in seeds). */
questlineID: string;
name: string;
desc: string;
game: string;
playtype: string;
/** Ordered list of `RawQuestDocument` references by their local index or slug. */
quests: Array<string>;
};