mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-06 05:48:00 +03:00
dasdatmeespresso
This commit is contained in:
@@ -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'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">
|
||||
|
||||
+6
-8
@@ -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 & 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 & 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'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>
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user