Add dialog for searching and adding mod

Still WIP, need to implement selecting a particular plugin under mod, which will require some backend changes.
This commit is contained in:
Tyler Hallada 2022-05-31 23:55:36 -04:00
parent db5fd884b0
commit a067f21f15
9 changed files with 261 additions and 20 deletions

91
components/AddModData.tsx Normal file
View File

@ -0,0 +1,91 @@
import { format } from "date-fns";
import React from "react";
import useSWRImmutable from "swr/immutable";
import { Mod, NEXUS_MODS_URL } from "./ModData";
import styles from "../styles/AddModData.module.css";
import { jsonFetcher } from "../lib/api";
type Props = {
selectedMod: number;
counts: Record<number, [number, number, number]> | null;
};
const AddModData: React.FC<Props> = ({ selectedMod, counts }) => {
const { data, error } = useSWRImmutable(
`https://mods.modmapper.com/${selectedMod}.json`,
(_) => jsonFetcher<Mod>(_)
);
if (error && error.status === 404) {
return <div>Mod could not be found.</div>;
} else if (error) {
return <div>{`Error loading mod data: ${error.message}`}</div>;
}
if (data === undefined)
return <div className={styles.status}>Loading...</div>;
if (data === null)
return <div className={styles.status}>Mod could not be found.</div>;
let numberFmt = new Intl.NumberFormat("en-US");
const modCounts = counts && counts[data.nexus_mod_id];
const total_downloads = modCounts ? modCounts[0] : 0;
const unique_downloads = modCounts ? modCounts[1] : 0;
const views = modCounts ? modCounts[2] : 0;
if (selectedMod && data) {
return (
<div className={styles.wrapper}>
<h3>
<a
href={`${NEXUS_MODS_URL}/mods/${data.nexus_mod_id}`}
target="_blank"
rel="noreferrer noopener"
className={styles.name}
>
{data.name}
</a>
</h3>
<div>
<strong>Category:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`}
target="_blank"
rel="noreferrer noopener"
>
{data.category_name}
</a>
{data.is_translation && <strong>&nbsp;(translation)</strong>}
</div>
<div>
<strong>Author:&nbsp;</strong>
<a
href={`${NEXUS_MODS_URL}/users/${data.author_id}`}
target="_blank"
rel="noreferrer noopener"
>
{data.author_name}
</a>
</div>
<div>
<strong>Uploaded:</strong>{" "}
{format(new Date(data.first_upload_at), "d MMM y")}
</div>
<div>
<strong>Last Update:</strong>{" "}
{format(new Date(data.last_update_at), "d MMM y")}
</div>
<div>
<strong>Total Downloads:</strong> {numberFmt.format(total_downloads)}
</div>
<div>
<strong>Unique Downloads:</strong>{" "}
{numberFmt.format(unique_downloads)}
</div>
</div>
);
}
return null;
};
export default AddModData;

View File

@ -0,0 +1,73 @@
import { createPortal } from "react-dom";
import React, { useState, useRef } from "react";
import AddModData from "./AddModData";
import SearchBar from "./SearchBar";
import styles from "../styles/AddModDialog.module.css";
type Props = {
counts: Record<number, [number, number, number]> | null;
};
const AddModDialog: React.FC<Props> = ({ counts }) => {
const [selectedMod, setSelectedMod] = useState<number | null>(null);
const [dialogShown, setDialogShown] = useState(false);
const searchInput = useRef<HTMLInputElement | null>(null);
const onAddModButtonClick = async () => {
setSelectedMod(null);
setDialogShown(true);
requestAnimationFrame(() => {
if (searchInput.current) searchInput.current.focus();
});
};
return (
<>
<button onClick={onAddModButtonClick}>Add mod</button>
{typeof window !== "undefined" &&
createPortal(
<dialog open={dialogShown} className={styles.dialog}>
<h3>Add mod</h3>
<SearchBar
counts={counts}
sidebarOpen={false}
placeholder="Search mods…"
onSelectResult={(selectedItem) => {
if (selectedItem) {
setSelectedMod(selectedItem.id);
}
}}
inputRef={searchInput}
/>
{selectedMod && (
<AddModData selectedMod={selectedMod} counts={counts} />
)}
<menu>
<button
onClick={() => {
setSelectedMod(null);
setDialogShown(false);
if (searchInput.current) searchInput.current.value = "";
}}
>
Cancel
</button>
<button
onClick={() => {
console.log(`Adding mod ${selectedMod}`);
setDialogShown(false);
}}
disabled={!selectedMod}
>
Add
</button>
</menu>
</dialog>,
document.body
)}
</>
);
};
export default AddModDialog;

View File

@ -839,7 +839,26 @@ const Map: React.FC = () => {
lastModified={cellsData && cellsData.lastModified}
/>
<ToggleLayersControl map={map} />
<SearchBar counts={counts} sidebarOpen={sidebarOpen} />
<SearchBar
counts={counts}
sidebarOpen={sidebarOpen}
placeholder="Search mods or cells…"
onSelectResult={(selectedItem) => {
if (!selectedItem) return;
if (
selectedItem.x !== undefined &&
selectedItem.y !== undefined
) {
router.push({
query: { cell: `${selectedItem.x},${selectedItem.y}` },
});
} else {
router.push({ query: { mod: selectedItem.id } });
}
}}
includeCells
fixed
/>
</div>
</div>
</>

View File

@ -32,7 +32,7 @@ export interface Mod {
cells: CellCoord[];
}
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
export const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
type Props = {
selectedMod: number;

View File

@ -10,6 +10,11 @@ import { jsonFetcher } from "../lib/api";
type Props = {
counts: Record<number, [number, number, number]> | null;
sidebarOpen: boolean;
placeholder: string;
onSelectResult: (item: SearchResult | null) => void;
includeCells?: boolean;
fixed?: boolean;
inputRef?: React.MutableRefObject<HTMLInputElement | null>;
};
interface Mod {
@ -37,7 +42,15 @@ const cellSearch = new MiniSearch({
});
cellSearch.addAll(cells);
const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
const SearchBar: React.FC<Props> = ({
counts,
sidebarOpen,
placeholder,
onSelectResult,
includeCells = false,
fixed = false,
inputRef,
}) => {
const router = useRouter();
const modSearch = useRef<MiniSearch<Mod> | null>(
@ -107,20 +120,16 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
})
);
}
if (includeCells) {
results = results.concat(cellSearch.search(inputValue));
}
setResults(results.splice(0, 30));
}
},
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
setSearchFocused(false);
if (selectedItem.x !== undefined && selectedItem.y !== undefined) {
router.push({
query: { cell: `${selectedItem.x},${selectedItem.y}` },
});
} else {
router.push({ query: { mod: selectedItem.id } });
}
onSelectResult(selectedItem);
if (searchInput.current) searchInput.current.blur();
reset();
}
@ -132,19 +141,24 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
<div
className={`${styles["search-bar"]} ${
searchFocused ? styles["search-bar-focused"] : ""
} ${sidebarOpen ? styles["search-bar-sidebar-open"] : ""}`}
} ${fixed ? styles["search-bar-fixed"] : ""} ${
sidebarOpen ? styles["search-bar-sidebar-open"] : ""
}`}
{...getComboboxProps()}
>
<input
{...getInputProps({
type: "text",
placeholder: "Search mods or cells…",
placeholder,
onFocus: () => setSearchFocused(true),
onBlur: () => {
if (!isOpen) setSearchFocused(false);
},
disabled: !data,
ref: searchInput,
ref: (ref) => {
searchInput.current = ref;
if (inputRef) inputRef.current = ref;
},
})}
/>
<ul

View File

@ -5,6 +5,7 @@ import { formatRelative } from "date-fns";
import arrow from "../public/img/arrow.svg";
import close from "../public/img/close.svg";
import AddModDialog from "./AddModDialog";
import CellData from "./CellData";
import ModData from "./ModData";
import PluginDetail from "./PluginDetail";
@ -154,6 +155,7 @@ const Sidebar: React.FC<Props> = ({
<DataDirPicker />
<PluginTxtEditor />
<PluginsList />
<AddModDialog counts={counts} />
{renderLastModified(lastModified)}
</div>
</div>

View File

@ -0,0 +1,9 @@
.wrapper {
margin-top: 24px;
margin-bottom: 24px;
}
a.name {
margin-top: 24px;
word-wrap: break-word;
}

View File

@ -0,0 +1,29 @@
.dialog {
top: 12px;
z-index: 8;
background-color: #fbefd5;
box-shadow: 0 0 1em black;
max-width: 400px;
min-width: 300px;
min-height: 400px;
}
.dialog[open] {
display: flex;
flex-direction: column;
}
.dialog h3 {
margin-top: 0px;
}
.dialog menu {
padding: 0;
display: flex;
justify-content: space-between;
margin-top: auto;
}
.button {
margin-bottom: 24px;
}

View File

@ -1,4 +1,4 @@
.search-bar {
.search-bar-fixed {
position: fixed;
top: 8px;
width: 150px;
@ -6,29 +6,33 @@
z-index: 2;
}
.search-bar.search-bar-focused {
.search-bar-fixed.search-bar-focused {
width: max(40vw, 250px);
left: calc(50% - max(20vw, 125px));
}
@media only screen and (min-width: 600px) {
.search-bar.search-bar-sidebar-open {
.search-bar-fixed.search-bar-sidebar-open {
left: calc(50% + 75px);
}
.search-bar.search-bar-focused.search-bar-sidebar-open {
.search-bar-fixed.search-bar-focused.search-bar-sidebar-open {
left: calc(50% - max(20vw, 125px) + 125px);
}
}
.search-bar input {
width: 150px;
width: 100%;
border-radius: 8px;
padding-left: 8px;
padding-right: 8px;
}
.search-bar.search-bar.search-bar-focused input {
.search-bar-fixed input {
width: 150px;
}
.search-bar-fixed.search-bar-focused input {
width: max(40vw, 250px);
border-radius: 8px;
padding-left: 8px;