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}
|
lastModified={cellsData && cellsData.lastModified}
|
||||||
/>
|
/>
|
||||||
<ToggleLayersControl map={map} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -32,7 +32,7 @@ export interface Mod {
|
|||||||
cells: CellCoord[];
|
cells: CellCoord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
export const NEXUS_MODS_URL = "https://www.nexusmods.com/skyrimspecialedition";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
selectedMod: number;
|
selectedMod: number;
|
||||||
|
@ -10,6 +10,11 @@ import { jsonFetcher } from "../lib/api";
|
|||||||
type Props = {
|
type Props = {
|
||||||
counts: Record<number, [number, number, number]> | null;
|
counts: Record<number, [number, number, number]> | null;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
onSelectResult: (item: SearchResult | null) => void;
|
||||||
|
includeCells?: boolean;
|
||||||
|
fixed?: boolean;
|
||||||
|
inputRef?: React.MutableRefObject<HTMLInputElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Mod {
|
interface Mod {
|
||||||
@ -37,7 +42,15 @@ const cellSearch = new MiniSearch({
|
|||||||
});
|
});
|
||||||
cellSearch.addAll(cells);
|
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 router = useRouter();
|
||||||
|
|
||||||
const modSearch = useRef<MiniSearch<Mod> | null>(
|
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));
|
setResults(results.splice(0, 30));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelectedItemChange: ({ selectedItem }) => {
|
onSelectedItemChange: ({ selectedItem }) => {
|
||||||
if (selectedItem) {
|
if (selectedItem) {
|
||||||
setSearchFocused(false);
|
setSearchFocused(false);
|
||||||
if (selectedItem.x !== undefined && selectedItem.y !== undefined) {
|
onSelectResult(selectedItem);
|
||||||
router.push({
|
|
||||||
query: { cell: `${selectedItem.x},${selectedItem.y}` },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
router.push({ query: { mod: selectedItem.id } });
|
|
||||||
}
|
|
||||||
if (searchInput.current) searchInput.current.blur();
|
if (searchInput.current) searchInput.current.blur();
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
@ -132,19 +141,24 @@ const SearchBar: React.FC<Props> = ({ counts, sidebarOpen }) => {
|
|||||||
<div
|
<div
|
||||||
className={`${styles["search-bar"]} ${
|
className={`${styles["search-bar"]} ${
|
||||||
searchFocused ? styles["search-bar-focused"] : ""
|
searchFocused ? styles["search-bar-focused"] : ""
|
||||||
} ${sidebarOpen ? styles["search-bar-sidebar-open"] : ""}`}
|
} ${fixed ? styles["search-bar-fixed"] : ""} ${
|
||||||
|
sidebarOpen ? styles["search-bar-sidebar-open"] : ""
|
||||||
|
}`}
|
||||||
{...getComboboxProps()}
|
{...getComboboxProps()}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
{...getInputProps({
|
{...getInputProps({
|
||||||
type: "text",
|
type: "text",
|
||||||
placeholder: "Search mods or cells…",
|
placeholder,
|
||||||
onFocus: () => setSearchFocused(true),
|
onFocus: () => setSearchFocused(true),
|
||||||
onBlur: () => {
|
onBlur: () => {
|
||||||
if (!isOpen) setSearchFocused(false);
|
if (!isOpen) setSearchFocused(false);
|
||||||
},
|
},
|
||||||
disabled: !data,
|
disabled: !data,
|
||||||
ref: searchInput,
|
ref: (ref) => {
|
||||||
|
searchInput.current = ref;
|
||||||
|
if (inputRef) inputRef.current = ref;
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<ul
|
<ul
|
||||||
|
@ -5,6 +5,7 @@ import { formatRelative } from "date-fns";
|
|||||||
|
|
||||||
import arrow from "../public/img/arrow.svg";
|
import arrow from "../public/img/arrow.svg";
|
||||||
import close from "../public/img/close.svg";
|
import close from "../public/img/close.svg";
|
||||||
|
import AddModDialog from "./AddModDialog";
|
||||||
import CellData from "./CellData";
|
import CellData from "./CellData";
|
||||||
import ModData from "./ModData";
|
import ModData from "./ModData";
|
||||||
import PluginDetail from "./PluginDetail";
|
import PluginDetail from "./PluginDetail";
|
||||||
@ -154,6 +155,7 @@ const Sidebar: React.FC<Props> = ({
|
|||||||
<DataDirPicker />
|
<DataDirPicker />
|
||||||
<PluginTxtEditor />
|
<PluginTxtEditor />
|
||||||
<PluginsList />
|
<PluginsList />
|
||||||
|
<AddModDialog counts={counts} />
|
||||||
{renderLastModified(lastModified)}
|
{renderLastModified(lastModified)}
|
||||||
</div>
|
</div>
|
||||||
</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;
|
position: fixed;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
width: 150px;
|
width: 150px;
|
||||||
@ -6,29 +6,33 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar.search-bar-focused {
|
.search-bar-fixed.search-bar-focused {
|
||||||
width: max(40vw, 250px);
|
width: max(40vw, 250px);
|
||||||
left: calc(50% - max(20vw, 125px));
|
left: calc(50% - max(20vw, 125px));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (min-width: 600px) {
|
@media only screen and (min-width: 600px) {
|
||||||
.search-bar.search-bar-sidebar-open {
|
.search-bar-fixed.search-bar-sidebar-open {
|
||||||
left: calc(50% + 75px);
|
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);
|
left: calc(50% - max(20vw, 125px) + 125px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar input {
|
.search-bar input {
|
||||||
width: 150px;
|
width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
padding-right: 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);
|
width: max(40vw, 250px);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
|
Loading…
Reference in New Issue
Block a user