Skip to content

Commit

Permalink
Implement basic search functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
vicr123 committed Feb 4, 2025
1 parent 49351ca commit bf047f8
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 1 deletion.
10 changes: 10 additions & 0 deletions Parlance.ClientApp/src/components/NavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import UserModal from "./modals/account/UserModal";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ParlanceLogo from "../images/parlance.svg";
import Icon from "@/components/Icon";
import { GlobalSearch } from "@/components/search/GlobalSearch";

export default function NavMenu() {
const [currentUser, setCurrentUser] = useState<string>();
const { t } = useTranslation();
const navigate = useNavigate();
const [globalSearchOpen, setGlobalSearchOpen] = useState(false);

UserManager.on("currentUserChanged", () => {
setCurrentUser(UserManager.currentUser?.username || t("LOG_IN"));
Expand Down Expand Up @@ -65,10 +68,17 @@ export default function NavMenu() {
</Button>
</div>
<div className={Styles.navbarButtonContainer}>
<Button onClick={() => setGlobalSearchOpen(true)}>
<Icon icon={"edit-find"} />
</Button>
<Button onClick={manageAccount}>{currentUser}</Button>
</div>
</div>
</div>
<GlobalSearch
open={globalSearchOpen}
onClose={() => setGlobalSearchOpen(false)}
/>
</header>
);
}
70 changes: 70 additions & 0 deletions Parlance.ClientApp/src/components/search/GlobalSearch.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
.scrim {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;

background: rgba(0, 0, 0, 0.5);
}

.searchContainer {
position: fixed;
top: 0;
left: 0;
right: 0;

display: flex;
flex-direction: column;
align-items: stretch;
}

.searchBoxContainer {
background: var(--background-color);
display: flex;
justify-content: center;
}

.searchBox.searchBox {
max-width: var(--content-width);
width: 100vw;

border: none;
outline: none;
font-size: 20pt;
}

.searchResultsContainer {
background: var(--background-color);
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 3px;
padding-top: 20px;
}

.searchResult {
max-width: var(--content-width);
width: 100vw;

padding: 3px;
border-radius: var(--border-radius);
cursor: default;
}

.searchResult:hover {
background: var(--hover-color);
}

.searchResult:active {
background: var(--active-color);
}

.selectedSearchResult {
background: var(--hover-color);
}

.searchTitle {
max-width: var(--content-width);
width: 100vw;
}
123 changes: 123 additions & 0 deletions Parlance.ClientApp/src/components/search/GlobalSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState, KeyboardEvent } from "react";
import Styles from "./GlobalSearch.module.css";
import { useTranslation } from "react-i18next";
import Fetch from "@/helpers/Fetch";
import PageHeading from "@/components/PageHeading";
import { useNavigate } from "react-router-dom";

interface SearchResult {
name: string;
href: string;
type: "project" | "subproject";
}

export function GlobalSearch({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [selected, setSelected] = useState<number>();
const { t } = useTranslation();
const navigate = useNavigate();
const searchBoxRef = useRef<HTMLInputElement>(null);

const activateSearchResult = (result: SearchResult) => {
navigate(result.href);
onClose();
};

useEffect(() => {
(async () => {
const query = searchQuery;
const response = await Fetch.post<SearchResult[]>("/api/search", {
query: query,
});

if (searchQuery == query) {
setSelected(undefined);
setSearchResults(response);
}
})();
}, [searchQuery]);

useEffect(() => {
if (open) {
searchBoxRef.current?.focus();
searchBoxRef.current?.select();
}
}, [open]);

if (!open) return null;

const keyPress = (event: KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case "ArrowUp":
setSelected(x =>
x == undefined
? searchResults.length - 1
: (x - 1 + searchResults.length) % searchResults.length,
);
event.preventDefault();
break;
case "ArrowDown":
setSelected(x =>
x == undefined ? 0 : (x + 1) % searchResults.length,
);
event.preventDefault();
break;
case "Enter":
if (searchResults.length != 0) {
activateSearchResult(searchResults[selected ?? 0]);
}
}
};

return (
<div>
<div className={Styles.scrim} onClick={onClose} />
<div className={Styles.searchContainer}>
<div className={Styles.searchBoxContainer}>
<input
type={"text"}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className={Styles.searchBox}
placeholder={t("Search for anything")}
ref={searchBoxRef}
onKeyDown={keyPress}
/>
</div>
<div className={Styles.searchResultsContainer}>
{searchResults.length > 0 && (
<>
<PageHeading
level={3}
className={Styles.searchTitle}
>
{t("Search Results")}
</PageHeading>
{searchResults.map((result, i) => (
<div
className={[
Styles.searchResult,
...(i == selected
? [Styles.selectedSearchResult]
: []),
].join(" ")}
onClick={() => activateSearchResult(result)}
onMouseEnter={() => setSelected(undefined)}
>
{result.name}
</div>
))}
</>
)}
</div>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions Parlance.Project/IParlanceProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ public interface IParlanceProject
public string VcsDirectory { get; }
public DateTime? Deadline { get; }
public IReadOnlyList<IParlanceSubproject> Subprojects { get; }
string SystemName { get; }
public IParlanceSubproject SubprojectBySystemName(string systemName);
}
2 changes: 2 additions & 0 deletions Parlance.Project/ParlanceProject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ public ParlanceProject(Database.Models.Project project)

public string ReadableName { get; }

public string SystemName => _project.SystemName;

public string Name => _project.Name;
public string VcsDirectory => _project.VcsDirectory;
public DateTime? Deadline { get; }
Expand Down
40 changes: 40 additions & 0 deletions Parlance/Controllers/SearchController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Parlance.Project;
using Parlance.Services.Projects;

namespace Parlance.Controllers;

[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("limiter")]
public class SearchController(IProjectService projectService) : Controller
{
[HttpPost]
public async Task<IActionResult> Search([FromBody] SearchRequestData data)
{
if (data.Query == string.Empty)
{
return Json(Enumerable.Empty<object>());
}

var projects = (await projectService.Projects()).ToList();
var subprojects = projects.SelectMany(project => project.GetParlanceProject().Subprojects);
return Json(projects.Where(project => project.Name.IndexOf(data.Query, StringComparison.InvariantCultureIgnoreCase) > 0).Select(project => new
{
Name = project.Name,
Href = $"/projects/{project.SystemName}",
Type = "project"
}).Union(subprojects.Where(subproject => subproject.Name.IndexOf(data.Query, StringComparison.InvariantCultureIgnoreCase) > 0).Select(subproject => new
{
Name = subproject.Name,
Href = $"projects/{subproject.Project.SystemName}/{subproject.SystemName}",
Type = "subproject"
})));
}

public class SearchRequestData
{
public required string Query { get; set; }
}
}
10 changes: 9 additions & 1 deletion Parlance/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,15 @@
builder.Services.AddGlossary(builder.Configuration);
builder.Services.AddNotifications(builder.Configuration);
builder.Services.AddMessagePipe();
await builder.Services.AddCldrAsync(builder.Configuration);

try
{
await builder.Services.AddCldrAsync(builder.Configuration);
}
catch
{
Console.WriteLine("CLDR data could not be downloaded");
}

builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<ISuperuserService, SuperuserService>();
Expand Down

0 comments on commit bf047f8

Please sign in to comment.