From 4ac4183bc9b3ddafe73e80a952ae71b56ea43528 Mon Sep 17 00:00:00 2001 From: carddev81 Date: Tue, 28 Jan 2025 17:15:08 -0600 Subject: [PATCH 1/3] feat: add new environment variable for kiwix server url --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 1d52901e3..1e8ec8544 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,6 +73,7 @@ services: - NATS_PASSWORD=dev - IMG_FILEPATH=/imgs - MIGRATION_DIR=backend/migrations + - KIWIX_SERVER_URL=https://kiwix.staging.unlockedlabs.xyz depends_on: kratos: condition: service_started From 2d8f38891e5039b6b5ddea22bc1aa031b28880f4 Mon Sep 17 00:00:00 2001 From: carddev81 Date: Tue, 28 Jan 2025 17:17:02 -0600 Subject: [PATCH 2/3] fix: add parsing for ampersands and a bit of logic for thumbnails --- provider-middleware/kiwix.go | 9 ++++--- provider-middleware/kiwix_data.go | 44 ++++++++++++++++++++----------- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/provider-middleware/kiwix.go b/provider-middleware/kiwix.go index b65822b43..f112a0e19 100644 --- a/provider-middleware/kiwix.go +++ b/provider-middleware/kiwix.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "os" + "strings" "sync" "gorm.io/gorm" @@ -49,7 +50,7 @@ func NewKiwixService(openContentProvider *models.OpenContentProvider, params *ma } func (ks *KiwixService) ImportLibraries(ctx context.Context, db *gorm.DB) error { - logger().Infoln("Importing libraries from Kiwix") + logger().Infoln("Importing libraries from Kiwix using the follwing url: ", ks.Url) req, err := http.NewRequest(http.MethodGet, ks.Url, nil) if err != nil { logger().Errorf("error creating request: %v", err) @@ -66,14 +67,16 @@ func (ks *KiwixService) ImportLibraries(ctx context.Context, db *gorm.DB) error logger().Errorf("error reading data: %v", err) return err } + //remove unencoded &'s from xml + replacer := strings.NewReplacer("&", "&") + xmlBody := replacer.Replace(string(body)) var feed Feed - err = xml.Unmarshal(body, &feed) + err = xml.Unmarshal([]byte(xmlBody), &feed) if err != nil { logger().Errorf("error parsing data: %v", err) return err } logger().Infof("Found %v libraries from Kiwix", len(feed.Entries)) - var externalIds []string for _, entry := range feed.Entries { select { diff --git a/provider-middleware/kiwix_data.go b/provider-middleware/kiwix_data.go index 1d7d16849..5004c5cd2 100644 --- a/provider-middleware/kiwix_data.go +++ b/provider-middleware/kiwix_data.go @@ -6,6 +6,7 @@ import ( "encoding/json" "encoding/xml" "fmt" + "html" "io" "mime/multipart" "net/http" @@ -20,20 +21,31 @@ type Feed struct { } type Entry struct { - ID string `xml:"id"` - Title string `xml:"title"` - Updated string `xml:"updated"` - Summary string `xml:"summary"` - Language string `xml:"language"` - Name string `xml:"name"` - Flavour string `xml:"flavour"` - Category string `xml:"category"` - Tags string `xml:"tags"` - ArticleCount int `xml:"articleCount"` - MediaCount int `xml:"mediaCount"` - Author Author `xml:"author"` - Publisher Publisher `xml:"publisher"` - Links []Link `xml:"link"` + ID string `xml:"id"` + Title string `xml:"title"` + Updated string `xml:"updated"` + Summary CustomString `xml:"summary"` + Language string `xml:"language"` + Name string `xml:"name"` + Flavour string `xml:"flavour"` + Category string `xml:"category"` + Tags string `xml:"tags"` + ArticleCount int `xml:"articleCount"` + MediaCount int `xml:"mediaCount"` + Author Author `xml:"author"` + Publisher Publisher `xml:"publisher"` + Links []Link `xml:"link"` +} + +type CustomString string + +func (cst *CustomString) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + var summary string + if err := dec.DecodeElement(&summary, &start); err != nil { + return err + } + *cst = CustomString(html.UnescapeString(summary)) + return nil } type Author struct { @@ -61,7 +73,7 @@ func (ks *KiwixService) IntoLibrary(entry Entry, providerId uint) *models.Librar ExternalID: models.StringPtr(entry.ID), Title: entry.Title, Language: models.StringPtr(entry.Language), - Description: models.StringPtr(entry.Summary), + Description: models.StringPtr(string(entry.Summary)), Url: url, ThumbnailUrl: models.StringPtr(thumbnailURL), VisibilityStatus: false, @@ -207,7 +219,7 @@ func (ks *KiwixService) ParseUrls(externId string, links []Link) (string, string if link.Type == "text/html" { url = link.Href } - if strings.Split(link.Type, "/")[0] == "image" { + if strings.Split(link.Type, "/")[0] == "image" || strings.Contains(link.Href, "catalog/v2/illustration") { if !ks.thumbnailExists(externId) { if thumbnail, err := ks.downloadAndHostThumbnailImg(externId, link.Href); err == nil { thumbnailURL = thumbnail From b374415e62f7d2c9dbf61563afdd650ea22b7b8b Mon Sep 17 00:00:00 2001 From: carddev81 Date: Tue, 28 Jan 2025 17:18:57 -0600 Subject: [PATCH 3/3] feat: add kiwix search functionality per #676 and fix change requests per PR review --- backend/seeder/main.go | 12 +- backend/src/database/libraries.go | 31 +- backend/src/handlers/libraries_handler.go | 80 +++++- backend/src/models/library.go | 125 +++++++++ backend/src/models/open_content.go | 4 +- frontend/src/Components/LibraryCard.tsx | 73 +++-- frontend/src/Components/LibraryLayout.tsx | 46 ++- .../Components/LibrarySearchResultsModal.tsx | 264 ++++++++++++++++++ .../cards/LibrarySearchResultCard.tsx | 44 +++ .../Components/dashboard/FeaturedContent.tsx | 41 ++- .../Components/inputs/LibrarySearchBar.tsx | 54 ++++ .../inputs/MultiSelectDropdownControl.tsx | 83 ++++++ frontend/src/Components/inputs/SearchBar.tsx | 4 +- frontend/src/Components/inputs/index.ts | 2 + frontend/src/Pages/LibraryViewer.tsx | 91 +++++- frontend/src/app.tsx | 6 +- frontend/src/common.ts | 17 ++ frontend/src/routeLoaders.ts | 31 +- 18 files changed, 950 insertions(+), 58 deletions(-) create mode 100644 frontend/src/Components/LibrarySearchResultsModal.tsx create mode 100644 frontend/src/Components/cards/LibrarySearchResultCard.tsx create mode 100644 frontend/src/Components/inputs/LibrarySearchBar.tsx create mode 100644 frontend/src/Components/inputs/MultiSelectDropdownControl.tsx diff --git a/backend/seeder/main.go b/backend/seeder/main.go index 637554fc8..41b1d9cb8 100644 --- a/backend/seeder/main.go +++ b/backend/seeder/main.go @@ -91,7 +91,7 @@ func seedTestData(db *gorm.DB) { } } kiwix := models.OpenContentProvider{ - Url: "https://library.kiwix.org", + Url: "https://kiwix.staging.unlockedlabs.xyz", Title: models.Kiwix, ThumbnailUrl: "https://images.fineartamerica.com/images/artworkimages/mediumlarge/3/llamas-wearing-party-hats-in-a-circle-looking-down-john-daniels.jpg", CurrentlyEnabled: true, @@ -109,17 +109,17 @@ func seedTestData(db *gorm.DB) { Language: models.StringPtr("eng,spa,ara"), Description: models.StringPtr("A collection of TED videos about ted connects"), Url: "/content/ted_mul_ted-connects_2024-08", - ThumbnailUrl: models.StringPtr("/catalog/v2/illustration/67440563-a62b-fabe-415c-4c3ee4546f78/?size=48"), + ThumbnailUrl: models.StringPtr("/kiwix.jpg"), VisibilityStatus: true, }, { OpenContentProviderID: kiwix.ID, - ExternalID: models.StringPtr("urn:uuid:84812c13-fa65-feb7-c206-4f22cc2e0f9a"), + ExternalID: models.StringPtr("urn:uuid:93321718-5228-676d-7e95-14bbe88fa38c"), Title: "Python Documentation", Language: models.StringPtr("eng"), Description: models.StringPtr("All documentation for Python"), - Url: "/content/docs.python.org_en_2024-09", - ThumbnailUrl: models.StringPtr("/catalog/v2/illustration/84812c13-fa65-feb7-c206-4f22cc2e0f9a/?size=48"), + Url: "/content/docs.python.org_en_2025-01", + ThumbnailUrl: models.StringPtr("/kiwix.jpg"), VisibilityStatus: true, }, { @@ -129,7 +129,7 @@ func seedTestData(db *gorm.DB) { Language: models.StringPtr("eng"), Description: models.StringPtr("The Canadian financial wiki"), Url: "/content/finiki_en_all_maxi_2024-06", - ThumbnailUrl: models.StringPtr("/catalog/v2/illustration/19e6fe12-09a9-0a38-5be4-71c0eba0a72d/?size=48"), + ThumbnailUrl: models.StringPtr("/kiwix.jpg"), VisibilityStatus: true, }} for idx := range kiwixLibraries { diff --git a/backend/src/database/libraries.go b/backend/src/database/libraries.go index 422befe07..874a5eb50 100644 --- a/backend/src/database/libraries.go +++ b/backend/src/database/libraries.go @@ -13,7 +13,16 @@ type LibraryResponse struct { IsFavorited bool `json:"is_favorited"` } -func (db *DB) GetAllLibraries(page, perPage, days int, userId, facilityId uint, visibility, orderBy, search string, isAdmin bool) (int64, []LibraryResponse, error) { +// Retrieves either a paginated list of libraries or all libraries based upon the given parameters. +// page - the page number for pagination +// perPage - the number of libraries to display on page +// userId - the userId for which libraries to display for +// facilityId - the facility id of where the libraries were favorited +// visibility - can either be featured, visible, hidden, or all +// orderBy - the order in which the results are returned +// isAdmin - true or false on whether the user is an administrator used to determine how to retrieve featured libraries +// all - true or false on whether or not to return all libraries without pagination +func (db *DB) GetAllLibraries(page, perPage, days int, userId, facilityId uint, visibility, orderBy, search string, isAdmin, all bool) (int64, []LibraryResponse, error) { var ( total int64 criteria string @@ -81,14 +90,15 @@ func (db *DB) GetAllLibraries(page, perPage, days int, userId, facilityId uint, AND f.open_content_provider_id = libraries.open_content_provider_id`) } tx = tx.Group("libraries.id").Order("favorite_count DESC") - default: tx = tx.Order(orderBy) } - if err := tx.Limit(perPage).Offset(calcOffset(page, perPage)).Find(&libraries).Error; err != nil { + if !all { + tx = tx.Limit(perPage).Offset(calcOffset(page, perPage)) + } + if err := tx.Find(&libraries).Error; err != nil { return 0, nil, newGetRecordsDBError(err, "libraries") } - return total, libraries, nil } @@ -101,6 +111,19 @@ func (db *DB) GetLibraryByID(id int) (*models.Library, error) { return &library, nil } +func (db *DB) GetLibrariesByIDs(ids []int) ([]models.Library, error) { + var libraries []models.Library + tx := db.Preload("OpenContentProvider").Where("id in ?", ids) + if len(ids) > 1 { + tx.Where("language = 'eng'") + } + if err := tx.Find(&libraries).Error; err != nil { + log.Errorln("unable to find libraries with these IDs") + return nil, newNotFoundDBError(err, "libraries") + } + return libraries, nil +} + func (db *DB) ToggleVisibilityAndRetrieveLibrary(id int) (*models.Library, error) { var library models.Library if err := db.Preload("OpenContentProvider").Find(&library, "id = ?", id).Error; err != nil { diff --git a/backend/src/handlers/libraries_handler.go b/backend/src/handlers/libraries_handler.go index 3190caf72..79312a2ac 100644 --- a/backend/src/handlers/libraries_handler.go +++ b/backend/src/handlers/libraries_handler.go @@ -3,24 +3,41 @@ package handlers import ( "UnlockEdv2/src/models" "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" "net/http" + "net/url" + "path" "strconv" + "strings" ) func (srv *Server) registerLibraryRoutes() []routeDef { axx := models.Feature(models.OpenContentAccess) return []routeDef{ {"GET /api/libraries", srv.handleIndexLibraries, false, axx}, + {"GET /api/libraries/search", srv.handleSearchLibraries, false, axx}, {"GET /api/libraries/{id}", srv.handleGetLibrary, false, axx}, {"PUT /api/libraries/{id}/toggle", srv.handleToggleLibraryVisibility, true, axx}, {"PUT /api/libraries/{id}/favorite", srv.handleToggleFavoriteLibrary, false, axx}, } } +// Retrieves either a paginated list of libraries or all libraries based upon the HTTP request parameters. +// Query Parameters: +// page - the page number for pagination +// perPage - the number of libraries to display on page +// search - the title or patial title of the libraries to search for +// order_by - (title|created_at|most_popular) the order in which the results are returned +// visibility - can either be featured, visible, hidden, or all +// all - true or false on whether or not to return all libraries without pagination func (srv *Server) handleIndexLibraries(w http.ResponseWriter, r *http.Request, log sLog) error { page, perPage := srv.getPaginationInfo(r) search := r.URL.Query().Get("search") orderBy := r.URL.Query().Get("order_by") + all := r.URL.Query().Get("all") == "true" days, err := strconv.Atoi(r.URL.Query().Get("days")) if err != nil { days = -1 @@ -34,7 +51,7 @@ func (srv *Server) handleIndexLibraries(w http.ResponseWriter, r *http.Request, showHidden = r.URL.Query().Get("visibility") } claims := r.Context().Value(ClaimsKey).(*Claims) - total, libraries, err := srv.Db.GetAllLibraries(page, perPage, days, claims.UserID, claims.FacilityID, showHidden, orderBy, search, claims.isAdmin()) + total, libraries, err := srv.Db.GetAllLibraries(page, perPage, days, claims.UserID, claims.FacilityID, showHidden, orderBy, search, claims.isAdmin(), all) if err != nil { return newDatabaseServiceError(err) } @@ -55,6 +72,67 @@ func (srv *Server) handleGetLibrary(w http.ResponseWriter, r *http.Request, log return writeJsonResponse(w, http.StatusOK, library) } +func (srv *Server) handleSearchLibraries(w http.ResponseWriter, r *http.Request, log sLog) error { + page, perPage := srv.getPaginationInfo(r) + search := r.URL.Query().Get("search") + ids := r.URL.Query()["library_id"] + libraryIDs := make([]int, 0, len(ids)) + for _, id := range ids { + if libID, err := strconv.Atoi(id); err == nil { + libraryIDs = append(libraryIDs, libID) + } + } + libraries, err := srv.Db.GetLibrariesByIDs(libraryIDs) + if err != nil { + log.add("library_ids", libraryIDs) + return newDatabaseServiceError(err) + } + nextPage := (page-1)*perPage + 1 + queryParams := url.Values{} + for _, library := range libraries { + queryParams.Add("books.name", path.Base(library.Url)) + } + queryParams.Add("format", "xml") + queryParams.Add("pattern", search) + kiwixSearchURL := fmt.Sprintf("%s/search?start=%d&pageLength=%d&%s", models.KiwixLibraryUrl, nextPage, perPage, queryParams.Encode()) + request, err := http.NewRequest(http.MethodGet, kiwixSearchURL, nil) + log.add("kiwix_search_url", kiwixSearchURL) + if err != nil { + return newInternalServerServiceError(err, "unable to create new request to kiwix") + } + resp, err := srv.Client.Do(request) + if err != nil { + return newInternalServerServiceError(err, "error executing kiwix search request") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.add("status_code", resp.StatusCode) + body, err := io.ReadAll(resp.Body) + if err != nil { + return newInternalServerServiceError(err, "executing request returned unexpected status, and failed to read error from its response") + } + log.add("kiwix_error", string(body)) + return newBadRequestServiceError(errors.New("api call to kiwix failed"), "response contained unexpected status code from kiwix") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return newInternalServerServiceError(err, "error reading body of response") + } + var rss models.RSS + err = xml.Unmarshal(body, &rss) + if err != nil { + return newInternalServerServiceError(err, "error parsing response body into XML") + } + total, err := strconv.ParseInt(strings.ReplaceAll(rss.Channel.TotalResults, ",", ""), 10, 64) + if err != nil { + return newInternalServerServiceError(err, "error parsing the total results value into an int64") + } + paginationData := models.NewPaginationInfo(page, perPage, int64(total)) + channels := make([]*models.KiwixChannel, 0, 1) //only ever going to be one KiwixChannel + channels = append(channels, rss.IntoKiwixChannel(libraries)) + return writePaginatedResponse(w, http.StatusOK, channels, paginationData) +} + func (srv *Server) handleToggleLibraryVisibility(w http.ResponseWriter, r *http.Request, log sLog) error { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { diff --git a/backend/src/models/library.go b/backend/src/models/library.go index f8243cb73..6326f885c 100644 --- a/backend/src/models/library.go +++ b/backend/src/models/library.go @@ -1,5 +1,11 @@ package models +import ( + "encoding/xml" + "fmt" + "strings" +) + type Library struct { DatabaseFields OpenContentProviderID uint `gorm:"not null" json:"open_content_provider_id"` @@ -35,3 +41,122 @@ type LibraryProxyPO struct { BaseUrl string VisibilityStatus bool } + +// Kiwix XML START here... +type RSS struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel Channel `xml:"channel"` +} + +type Channel struct { + Title string `xml:"title"` + Description string `xml:"description"` + TotalResults string `xml:"http://a9.com/-/spec/opensearch/1.1/ totalResults"` + StartIndex string `xml:"http://a9.com/-/spec/opensearch/1.1/ startIndex"` + ItemsPerPage string `xml:"http://a9.com/-/spec/opensearch/1.1/ itemsPerPage"` + Items []Item `xml:"item"` +} + +type Item struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description Description `xml:"description"` + Book Book `xml:"book"` + WordCount string `xml:"wordCount"` +} + +type Description struct { + RawText string `xml:"-"` +} + +type Book struct { + Title string `xml:"title"` +} + +type KiwixChannel struct { + Title string `json:"title"` + Link string `json:"link"` + Description string `json:"description"` + TotalResults string `json:"total_results"` + StartIndex string `json:"start_index"` + ItemsPerPage string `json:"items_per_page"` + Items []KiwixItem `json:"items"` +} + +type KiwixItem struct { + Library + PageTitle string `json:"page_title"` +} + +func (rss *RSS) IntoKiwixChannel(libraries []Library) *KiwixChannel { + channel := &KiwixChannel{ + Title: rss.Channel.Title, + Description: rss.Channel.Description, + TotalResults: rss.Channel.TotalResults, + StartIndex: rss.Channel.StartIndex, + ItemsPerPage: rss.Channel.ItemsPerPage, + Items: []KiwixItem{}, + } + for _, item := range rss.Channel.Items { + library := getLibrary(libraries, item.Link) + kiwixItem := KiwixItem{ + Library: Library{ + DatabaseFields: DatabaseFields{ + ID: library.ID, + }, + Url: fmt.Sprintf("/api/proxy/libraries/%d%s", library.ID, item.Link), + ThumbnailUrl: library.ThumbnailUrl, + Description: &item.Description.RawText, + Title: item.Book.Title, + }, + PageTitle: item.Title, + } + channel.Items = append(channel.Items, kiwixItem) + } + return channel +} + +func getLibrary(libraries []Library, link string) *Library { + var foundLibrary *Library + for _, library := range libraries { + if strings.HasPrefix(link, library.Url) { + foundLibrary = &library + break + } + } + return foundLibrary +} + +// isolates and keeps the bolded words in the description +func (d *Description) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error { + var rawContent strings.Builder + + for { //just keep looping till return nil + tok, err := dec.Token() + if err != nil { + return err + } + switch ty := tok.(type) { + case xml.StartElement: + if ty.Name.Local == "b" { + var boldText string + err = dec.DecodeElement(&boldText, &ty) + if err != nil { + return err + } + rawContent.WriteString("" + boldText + "") //write it back into the description, need this + } else { //just in case another tag is found + rawContent.WriteString("<" + ty.Name.Local + ">") + } + case xml.EndElement: + if ty.Name.Local == start.Name.Local { + d.RawText = rawContent.String() + return nil + } + rawContent.WriteString("") + case xml.CharData: + rawContent.WriteString(string(ty)) + } + } +} diff --git a/backend/src/models/open_content.go b/backend/src/models/open_content.go index d55680ffb..4d9e023e0 100644 --- a/backend/src/models/open_content.go +++ b/backend/src/models/open_content.go @@ -2,6 +2,7 @@ package models import ( "fmt" + "os" "strings" "time" @@ -69,7 +70,6 @@ const ( KolibriDescription string = "Kolibri provides an extensive library of educational content suitable for all learning levels." KiwixThumbnailURL string = "/kiwix.jpg" KiwixDescription string = "Kiwix is an offline reader that allows you to host a wide array of educational content." - KiwixLibraryUrl string = "https://library.kiwix.org" YoutubeThumbnail string = "/youtube.png" Youtube string = "Youtube" YoutubeApi string = "https://www.googleapis.com/youtube/v3/videos" @@ -80,6 +80,8 @@ const ( HelpfulLinksDescription string = "Hand picked helpful links for users" ) +var KiwixLibraryUrl string = os.Getenv("KIWIX_SERVER_URL") + func (cp *OpenContentProvider) BeforeCreate(tx *gorm.DB) error { if cp.Title == Youtube && cp.Url == "" { cp.Url = YoutubeApi diff --git a/frontend/src/Components/LibraryCard.tsx b/frontend/src/Components/LibraryCard.tsx index 01ebbb238..ecbd4a835 100644 --- a/frontend/src/Components/LibraryCard.tsx +++ b/frontend/src/Components/LibraryCard.tsx @@ -6,19 +6,23 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useToast } from '@/Context/ToastCtx'; import { AdminRoles } from '@/useAuth'; import ULIComponent from '@/Components/ULIComponent'; -import { StarIcon } from '@heroicons/react/24/solid'; -import { StarIcon as StarIconOutline } from '@heroicons/react/24/outline'; -import { FlagIcon } from '@heroicons/react/24/solid'; -import { FlagIcon as FlagIconOutline } from '@heroicons/react/24/outline'; +import { StarIcon, FlagIcon } from '@heroicons/react/24/solid'; +import { + StarIcon as StarIconOutline, + FlagIcon as FlagIconOutline, + MagnifyingGlassIcon +} from '@heroicons/react/24/outline'; export default function LibraryCard({ library, mutate, - role + role, + onSearchClick }: { library: Library; mutate?: () => void; role: UserRole; + onSearchClick?: () => void; }) { const { toaster } = useToast(); const [visible, setVisible] = useState(library.visibility_status); @@ -57,7 +61,12 @@ export default function LibraryCard({ toaster(`Library {${actionString}}`, ToastState.error); } } - + const handleSearchClick = (e?: MouseEvent) => { + if (e) e.stopPropagation(); + if (onSearchClick) { + onSearchClick(); + } + }; return (

{library.title}

- -
void handleToggleAction('favorite', e)} - > - {!route.pathname.includes('knowledge-insights') && ( +
+ {!route.pathname.includes('knowledge-insights') && ( +
- )} +
+ )} +
void handleToggleAction('favorite', e)}> + {!route.pathname.includes('knowledge-insights') && ( + + )} +
diff --git a/frontend/src/Components/LibraryLayout.tsx b/frontend/src/Components/LibraryLayout.tsx index e7e083bc1..3dd7e13c4 100644 --- a/frontend/src/Components/LibraryLayout.tsx +++ b/frontend/src/Components/LibraryLayout.tsx @@ -10,11 +10,12 @@ import DropdownControl from '@/Components/inputs/DropdownControl'; import SearchBar from '@/Components/inputs/SearchBar'; import LibraryCard from '@/Components/LibraryCard'; import { isAdministrator, useAuth } from '@/useAuth'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import useSWR from 'swr'; import Pagination from './Pagination'; import { AxiosError } from 'axios'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import LibrarySearchResultsModal from '@/Components/LibrarySearchResultsModal'; export default function LibaryLayout({ studentView @@ -25,6 +26,35 @@ export default function LibaryLayout({ if (!user) { return null; } + const navigate = useNavigate(); + const [searchModalLibrary, setSearchModalLibrary] = useState(null); + const modalRef = useRef(null); + //execute when the the searchModalLibrary changes + useEffect(() => { + if (searchModalLibrary && modalRef.current) { + modalRef.current.style.visibility = 'visible'; + modalRef.current.showModal(); + } + }, [searchModalLibrary]); + + const openSearchModal = (library: Library) => { + setSearchModalLibrary(library);//fire off useEffect + }; + const closeSearchModal = () => { + if (modalRef.current) { + modalRef.current.style.visibility = 'hidden'; + modalRef.current.close(); + } + setSearchModalLibrary(null); + }; + const navToLibraryViewer = (url: string, title: string) => { + navigate( + `/viewer/libraries/${searchModalLibrary?.id}`, + { + state: { url: url, title: title } + } + ); + } const [searchTerm, setSearchTerm] = useState(''); const [filterLibraries, setFilterLibraries] = useState( FilterLibraries['All Libraries'] @@ -88,6 +118,7 @@ export default function LibaryLayout({ <>
@@ -111,6 +142,16 @@ export default function LibaryLayout({ /> )} + {searchModalLibrary && ( + + )}
{libraries?.data.map((library) => ( @@ -119,6 +160,7 @@ export default function LibaryLayout({ library={library} mutate={updateLibrary} role={adminWithStudentView() ? UserRole.Student : role} + onSearchClick={() => openSearchModal(library)} /> ))}
diff --git a/frontend/src/Components/LibrarySearchResultsModal.tsx b/frontend/src/Components/LibrarySearchResultsModal.tsx new file mode 100644 index 000000000..7a53859de --- /dev/null +++ b/frontend/src/Components/LibrarySearchResultsModal.tsx @@ -0,0 +1,264 @@ +import { + KiwixChannel, + PaginationMeta, + Library, + ServerResponseMany +} from '@/common'; +import { CloseX, LibrarySearchBar, MultiSelectDropdown } from './inputs'; +import { useLoaderData } from 'react-router-dom'; +import { forwardRef, useRef, useState, useEffect } from 'react'; +import Pagination from './Pagination'; +import API from '@/api/api'; +import LibrarySearchResultCard from './cards/LibrarySearchResultCard'; + +interface LibrarySearchResultsModalProps { + useInternalSearchBar?: boolean; + searchPlaceholder?: string; + libraryId?: number; + onItemClick: (url: string, title: string, id?: number) => void; + onModalClose: () => void; +} + +interface SearchEventPO { + searchTerm: string; + page: number; + perPage: number; +} + +const LibrarySearchResultsModal = forwardRef< + HTMLDialogElement, + LibrarySearchResultsModalProps +>(function SearchResultsModal( + { + useInternalSearchBar = false, + searchPlaceholder = '', + libraryId, + onItemClick, + onModalClose + }, + ref +) { + const { libraryOptions } = useLoaderData() as { + libraryOptions: Library[]; + }; + const [selectedOptions, setSelectedOptions] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [placeholder, setPlaceholder] = useState(searchPlaceholder); + const [isSearchValid, setIsSearchValid] = useState(false); + const searchBarRef = useRef(null); + useEffect(() => { + if (ref && typeof ref !== 'function' && ref.current?.open) { + searchBarRef.current?.focus(); + } + }, [ref]); + const BlankChannel = { + title: 'Search', + link: '', + book: '', + thumbnail_url: '', + description: '', + total_results: '0', + start_index: '0', + items_per_page: '10', + items: [] + }; + const [searchResults, setSearchResults] = + useState(BlankChannel); + const [meta, setMeta] = useState({ + current_page: 1, + per_page: 10, + total: 0, + last_page: 0 + }); + const [isLoading, setIsLoading] = useState(false); + const [searchError, setSearchError] = useState(null); + const scrollContainerRef = useRef(null); + const handleSearch = async ( + page: number, + perPage: number, + term: string = searchTerm + ) => { + if (ref && typeof ref !== 'function') { + if (ref.current?.open) { + searchResults.items = []; //clear results on pagination, prevents jumpiness + } + } + setIsLoading(true); + setSearchError(null); + const libraryIDs = selectedOptions.length > 0 ? selectedOptions : [libraryId]; + const urlParams = libraryIDs.map((libID) => `libraryId=${libID}`).join('&'); + try { + const response = (await API.get( + `libraries/search?search=${term}&${urlParams}&page=${page}&per_page=${perPage}` + )) as ServerResponseMany; + if (response.success) { + setMeta(response.meta); + setSearchResults(response.data[0]); + } else { + setSearchError('The search could not be completed due to an unexpected error. Please try again later.'); + } + } catch { + setSearchError( + 'The search could not be completed due to a technical issue. Please try again later.' + ); + } finally { + setIsLoading(false); + } + }; + const scrollToTop = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }; + const setDefaultOption = () => { + const selected = libraryOptions + .filter((op) => op.id === Number(libraryId)) + .map((option) => option.id); + setSelectedOptions(selected); + }; + const handleSetPage = (page: number) => { + scrollToTop(); + void handleSearch(page, Number(searchResults.items_per_page)); + }; + const handleSetPerPage = (perPage: number) => { + scrollToTop(); + void handleSearch(1, perPage); + }; + const handleOnBlurSearch = () => { + if (selectedOptions.length > 0) { + if (useInternalSearchBar && searchTerm.trim() === '') { + return; + } + void handleSearch(1, 10); + } + }; + const handleSelectionChange = (selected: number[]) => { + if (useInternalSearchBar && selected.length > 1) { + setPlaceholder('Search Libraries...'); + } + setSelectedOptions(selected); + }; + const parseNumber = (num: string) => { + return Number(num.replace(/,/g, '')); + }; + const currentEndResultsPage = Math.min( + Number(parseNumber(searchResults.start_index)) + + Number(searchResults.items_per_page) - + 1, + parseNumber(searchResults.total_results) + ); + const currentPageDisplay = `Results ${searchResults.start_index}-${currentEndResultsPage.toLocaleString('en-US')} of ${searchResults.total_results}`; + useEffect(() => { + setDefaultOption(); + }, []); + useEffect(() => { + //used for externally hooking an event call to execute search + const executeSearchListener = (event: CustomEvent) => { + const { searchTerm, page, perPage } = event.detail; + setSearchTerm(searchTerm); + setIsSearchValid(searchTerm.trim() !== ''); + void handleSearch(page, perPage, searchTerm); + }; + + if (ref && typeof ref !== 'function') { + ref.current?.addEventListener( + 'executeHandleSearch', + executeSearchListener as EventListener + ); + + return () => { + ref.current?.removeEventListener( + 'executeHandleSearch', + executeSearchListener as EventListener + ); + }; + } + }, [ref, handleSearch]); + const handleCloseModal = () => { + setDefaultOption(); + onModalClose(); + setSearchResults(BlankChannel); + }; + return ( + +
+
+
+
+

{searchResults.title}

+

{currentPageDisplay}

+
+ {useInternalSearchBar && ( + { + setSearchTerm(value); + setIsSearchValid(value.trim() !== ''); + }} + onSearchClick={() => void handleSearch(1, 10)} + ref={searchBarRef} + /> + )} + + +
+
+
+ {searchResults.items?.map((item, index) => ( + + ))} +
+ {isLoading ? ( +
+ +

Loading...

+
+ ) : searchError ? ( +
+

+ {searchError} +

+
+ ) : searchResults.title === 'Search' && useInternalSearchBar ? ( +
+

Execute a search

+
+ ) : searchResults.items && searchResults.items.length === 0 ? ( +
+

No Results Found

+
+ ) : ( + ' ' + )} +
+ +
+
+
+ ); +}); +export default LibrarySearchResultsModal; diff --git a/frontend/src/Components/cards/LibrarySearchResultCard.tsx b/frontend/src/Components/cards/LibrarySearchResultCard.tsx new file mode 100644 index 000000000..f1da22ac5 --- /dev/null +++ b/frontend/src/Components/cards/LibrarySearchResultCard.tsx @@ -0,0 +1,44 @@ +import { KiwixItem } from '@/common'; + +export default function LibrarySearchResultCard({ + item, + onItemClick +}: { + item: KiwixItem; + onItemClick: (url: string, title: string, id?: number) => void; +}) { + const boldKeywords = (description: string) => { + const rawPieces = description.split(/(.*?)<\/b>/g); + const elements = rawPieces.map((word, index) => + index % 2 === 1 ? {word} : word + ); + return <>{elements}; + }; + return ( +
onItemClick(item.url, item.title, item.id)} + > +
+
+
+ {`${item.title} +
+
+
+

{item.page_title}

+

{item.title}

+
+
+
+

+ {item.description ? boldKeywords(item.description) : null} +

+
+
+ ); +} diff --git a/frontend/src/Components/dashboard/FeaturedContent.tsx b/frontend/src/Components/dashboard/FeaturedContent.tsx index d87419460..cab7887b6 100644 --- a/frontend/src/Components/dashboard/FeaturedContent.tsx +++ b/frontend/src/Components/dashboard/FeaturedContent.tsx @@ -1,8 +1,9 @@ import { Library, UserRole } from '@/common'; import LibraryCard from '../LibraryCard'; import { useAuth } from '@/useAuth'; -import { useState } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import LibrarySearchResultsModal from '../LibrarySearchResultsModal'; export default function FeaturedContent({ featured, mutate @@ -18,7 +19,32 @@ export default function FeaturedContent({ user?.role === UserRole.Admin || user?.role === UserRole.SystemAdmin; const navigate = useNavigate(); - + const [searchModalLibrary, setSearchModalLibrary] = useState(null); + const modalRef = useRef(null); + useEffect(() => { + if (searchModalLibrary && modalRef.current) { + modalRef.current.style.visibility = 'visible'; + modalRef.current.showModal(); + } + }, [searchModalLibrary]); + const openSearchModal = (library: Library) => { + setSearchModalLibrary(library);//fire off useEffect + }; + const closeSearchModal = () => { + if (modalRef.current) { + modalRef.current.style.visibility = 'hidden'; + modalRef.current.close(); + } + setSearchModalLibrary(null); + }; + const navToLibraryViewer = (url: string, title: string) => { + navigate( + `/viewer/libraries/${searchModalLibrary?.id}`, + { + state: { url: url, title: title } + } + ); + } const handleEmptyStateClick = () => { if (isAdmin) { navigate('/knowledge-center-management/libraries', { @@ -34,6 +60,16 @@ export default function FeaturedContent({ <>

Featured Content

+ {searchModalLibrary && ( + + )} {featured.length > 0 ? ( <>
@@ -43,6 +79,7 @@ export default function FeaturedContent({ library={item} role={UserRole.Student} mutate={mutate} + onSearchClick={() => openSearchModal(item)} /> ))}
diff --git a/frontend/src/Components/inputs/LibrarySearchBar.tsx b/frontend/src/Components/inputs/LibrarySearchBar.tsx new file mode 100644 index 000000000..18d37c329 --- /dev/null +++ b/frontend/src/Components/inputs/LibrarySearchBar.tsx @@ -0,0 +1,54 @@ +import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; +import { forwardRef } from 'react'; + +interface LibrarySearchBarProps { + searchTerm: string; + isSearchValid: boolean; + searchPlaceholder: string; + changeCallback: (arg: string) => void; + onSearchClick: (page: number, perPage: number) => void; //default to 10 +} + +export const LibrarySearchBar = forwardRef< + HTMLInputElement, + LibrarySearchBarProps + >(function SearchResultsModal( + { + searchTerm, + isSearchValid, + searchPlaceholder, + changeCallback, + onSearchClick, + }, + ref + ) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isSearchValid) { + e.preventDefault(); + onSearchClick(1, 10); + } + }; + return ( + + ); +}); diff --git a/frontend/src/Components/inputs/MultiSelectDropdownControl.tsx b/frontend/src/Components/inputs/MultiSelectDropdownControl.tsx new file mode 100644 index 000000000..a1bc75a23 --- /dev/null +++ b/frontend/src/Components/inputs/MultiSelectDropdownControl.tsx @@ -0,0 +1,83 @@ +import { Library } from '@/common'; +import { useRef, useState } from 'react'; + +interface MultiSelectDropdownProps { + label?: string; + options: Library[]; + selectedOptions: number[]; + addSelectAllOption?: boolean; + onSelectionChange: (selected: number[]) => void; + onBlurSearch: () => void; +} + +export function MultiSelectDropdown({ + label, + options, + selectedOptions, + onSelectionChange, + onBlurSearch +}: MultiSelectDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const toggleDropdown = () => setIsOpen(!isOpen); + const handleCheckboxChange = (key: number) => { + const updatedSelection = selectedOptions.includes(key) + ? selectedOptions.filter((item) => item !== key) + : [...selectedOptions, key]; + onSelectionChange(updatedSelection); + }; + const displayText = () => { + if (selectedOptions.length === 1) { + return ( + options.find((option) => option.id === selectedOptions[0]) + ?.title ?? label + ); + } + if (selectedOptions.length > 1) { + return `${selectedOptions.length} selected`; + } + return label; + }; + const handleBlur = (event: React.FocusEvent) => { + if (!dropdownRef.current?.contains(event.relatedTarget as Node)) { + onBlurSearch(); + setIsOpen(false); + } + }; + return ( + + ); +} diff --git a/frontend/src/Components/inputs/SearchBar.tsx b/frontend/src/Components/inputs/SearchBar.tsx index 76238b313..28b8a4bbe 100644 --- a/frontend/src/Components/inputs/SearchBar.tsx +++ b/frontend/src/Components/inputs/SearchBar.tsx @@ -2,9 +2,11 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; export default function SearchBar({ searchTerm, + searchPlaceholder, changeCallback }: { searchTerm: string; + searchPlaceholder?: string changeCallback: (arg: string) => void; }) { return ( @@ -13,7 +15,7 @@ export default function SearchBar({ changeCallback(e.target.value)} diff --git a/frontend/src/Components/inputs/index.ts b/frontend/src/Components/inputs/index.ts index 1afc69010..30077ab06 100644 --- a/frontend/src/Components/inputs/index.ts +++ b/frontend/src/Components/inputs/index.ts @@ -3,3 +3,5 @@ export { TextAreaInput } from './TextAreaInput'; export { DropdownInput } from './DropdownInput'; export { TextInput } from './TextInput'; export { SubmitButton } from './SubmitButton'; +export { MultiSelectDropdown } from './MultiSelectDropdownControl'; +export { LibrarySearchBar } from './LibrarySearchBar'; diff --git a/frontend/src/Pages/LibraryViewer.tsx b/frontend/src/Pages/LibraryViewer.tsx index 3837b8dea..2cfb21a52 100644 --- a/frontend/src/Pages/LibraryViewer.tsx +++ b/frontend/src/Pages/LibraryViewer.tsx @@ -1,10 +1,16 @@ -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useEffect, useRef, useState } from 'react'; +import { useParams, useLocation, useNavigate } from 'react-router-dom'; import Error from '@/Pages/Error'; import API from '@/api/api'; import { Library, ServerResponseOne } from '@/common'; import { usePathValue } from '@/Context/PathValueCtx'; import { setGlobalPageTitle } from '@/Components/PageNav'; +import { LibrarySearchBar } from '@/Components/inputs'; +import LibrarySearchResultsModal from '@/Components/LibrarySearchResultsModal'; + +interface UrlNavState { + url?: string; +} export default function LibraryViewer() { const { id: libraryId } = useParams(); @@ -12,6 +18,57 @@ export default function LibraryViewer() { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const { setPathVal } = usePathValue(); + const [searchPlaceholder, setSearchPlaceholder] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const modalRef = useRef(null); + const navigate = useNavigate(); + const location = useLocation() as { state: UrlNavState }; + const { url } = location.state || {}; + const openModal = () => { + if (modalRef.current) { + modalRef.current.style.visibility = 'visible'; + modalRef.current.showModal(); + } + }; + const closeModal = () => { + if (modalRef.current) { + modalRef.current.style.visibility = 'hidden'; + modalRef.current.close(); + } + }; + const handleSearchResultClick = (url: string, title: string, libId?: number) => { + if(Number(libraryId) === libId){ + setSrc(url); + }else{ + navigate( + `/viewer/libraries/${libId}`, + + { + state: { url: url, title: title }, + replace: true + } + ); + } + setSearchPlaceholder('Search ' + title); + closeModal(); + }; + const handleSearch = () => { + if (modalRef.current) { + if (!modalRef.current.open) { + openModal(); + } + //needed a way to call + modalRef.current.dispatchEvent( + new CustomEvent('executeHandleSearch', { + detail: { + searchTerm: searchTerm, + page: 1, + perPage: 10 + } + }) + ); + } + }; useEffect(() => { const fetchLibraryData = async () => { @@ -21,16 +78,22 @@ export default function LibraryViewer() { `libraries/${libraryId}` )) as ServerResponseOne; if (resp.success) { - setGlobalPageTitle(resp.data.title); + const title = resp.data.title; + setGlobalPageTitle(title); + setSearchPlaceholder('Search ' + title); setPathVal([ - { path_id: ':library_name', value: resp.data.title } + { path_id: ':library_name', value: title } ]); } const response = await fetch( `/api/proxy/libraries/${libraryId}/` ); if (response.ok) { - setSrc(response.url); + if (url && url !== "") { + setSrc(url); + } else { + setSrc(response.url); + } } else if (response.status === 404) { setError('Library not found'); } else { @@ -47,10 +110,26 @@ export default function LibraryViewer() { sessionStorage.removeItem('tag'); }; }, [libraryId]); - return (
+
+

Library Viewer

+ + +
{isLoading ? (
diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index bd79d7c2d..775d3dff1 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -42,7 +42,8 @@ import { getAdminLevel1Data, getFacilities, getStudentLayer2Data, - getStudentLevel1Data + getStudentLevel1Data, + getLibraryOptions } from './routeLoaders.ts'; import FacilityManagement from '@/Pages/FacilityManagement.tsx'; @@ -185,6 +186,7 @@ const router = createBrowserRouter([ { path: 'libraries', element: , + loader: getLibraryOptions, errorElement: , handle: { title: 'Libraries', @@ -232,6 +234,7 @@ const router = createBrowserRouter([ path: 'viewer/libraries/:id', element: , errorElement: , + loader: getLibraryOptions, handle: { title: 'Library Viewer', path: [ @@ -440,6 +443,7 @@ const router = createBrowserRouter([ { path: 'libraries', element: , + loader: getLibraryOptions, errorElement: , handle: { title: 'Libraries Management', diff --git a/frontend/src/common.ts b/frontend/src/common.ts index c226b6c4f..7bf075104 100644 --- a/frontend/src/common.ts +++ b/frontend/src/common.ts @@ -703,3 +703,20 @@ export interface ActivityMapData { total_time: string; quartile: number; } + +export interface KiwixChannel { + book: string; + title: string; + thumbnail_url: string; + link: string; + description: string; + total_results: string; + start_index: string; + items_per_page: string; + items?: KiwixItem[]; +} + +export interface KiwixItem extends Library { + page_title: string; +} + diff --git a/frontend/src/routeLoaders.ts b/frontend/src/routeLoaders.ts index 9d2904347..1aa51ec6b 100644 --- a/frontend/src/routeLoaders.ts +++ b/frontend/src/routeLoaders.ts @@ -6,7 +6,8 @@ import { HelpfulLinkAndSort, Library, UserCoursesInfo, - ActivityMapData + ActivityMapData, + UserRole, } from './common'; import API from './api/api'; import { fetchUser } from './useAuth'; @@ -14,12 +15,14 @@ import { fetchUser } from './useAuth'; export const getStudentLevel1Data: LoaderFunction = async () => { const user = await fetchUser(); if (!user) return; - const [resourcesResp, userContentResp, facilityContentResp, favoritesResp] = + const visibilityParam = user.role === UserRole.Student ? "&visibility=visible" : "&visibility=all" + const [resourcesResp, userContentResp, facilityContentResp, favoritesResp, libraryResp] = await Promise.all([ API.get(`helpful-links?visibility=true&per_page=5`), API.get(`open-content/activity/${user.id}`), API.get(`open-content/activity`), - API.get(`open-content/favorites`) + API.get(`open-content/favorites`), + API.get(`libraries?all=true&order_by=title${visibilityParam}`) ]); const links = resourcesResp.data as HelpfulLinkAndSort; @@ -33,12 +36,15 @@ export const getStudentLevel1Data: LoaderFunction = async () => { const favoriteOpenContent = favoritesResp.success ? (favoritesResp.data as OpenContentItem[]) : []; - + const libraryOptions = libraryResp.success + ? (libraryResp.data as Library[]) + : []; return json({ helpfulLinks: helpfulLinks, topUserContent: topUserOpenContent, topFacilityContent: topFacilityOpenContent, - favorites: favoriteOpenContent + favorites: favoriteOpenContent, + libraryOptions: libraryOptions }); }; @@ -86,3 +92,18 @@ export const getFacilities: LoaderFunction = async () => { } return json(null); }; + +export const getLibraryOptions: LoaderFunction = async ({ request }: { request: Request }) => { + const user = await fetchUser(); + if (!user) return; + const visibilityParam = user.Role != UserRole.Student && (request.url.includes('management') || request.url.includes('viewer')) ? "&visibility=all" : "&visibility=visible"; + const [libraryResp] = await Promise.all([ + API.get(`libraries?all=true&order_by=title${visibilityParam}`) + ]); + const libraryOptions = libraryResp.success + ? (libraryResp.data as Library[]) + : []; + return json({ + libraryOptions: libraryOptions + }); +};