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:
parent
db5fd884b0
commit
a067f21f15
91
components/AddModData.tsx
Normal file
91
components/AddModData.tsx
Normal 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: </strong>
|
||||
<a
|
||||
href={`${NEXUS_MODS_URL}/mods/categories/${data.category_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{data.category_name}
|
||||
</a>
|
||||
{data.is_translation && <strong> (translation)</strong>}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Author: </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;
|
73
components/AddModDialog.tsx
Normal file
73
components/AddModDialog.tsx
Normal 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;
|
@ -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>
|
||||
</>
|
||||
|
@ -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;
|
||||
|
@ -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 }) => {
|
||||
})
|
||||
);
|
||||
}
|
||||
results = results.concat(cellSearch.search(inputValue));
|
||||
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
|
||||
|
@ -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>
|
||||
|
9
styles/AddModData.module.css
Normal file
9
styles/AddModData.module.css
Normal file
@ -0,0 +1,9 @@
|
||||
.wrapper {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
a.name {
|
||||
margin-top: 24px;
|
||||
word-wrap: break-word;
|
||||
}
|
29
styles/AddModDialog.module.css
Normal file
29
styles/AddModDialog.module.css
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user