diff --git a/.vscode/launch.json b/.vscode/launch.json index 9311995..39deac0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,13 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Debug movies-db-ui", + "request": "launch", + "type": "chrome", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/movies-db-ui", + }, { "type": "lldb", "request": "launch", diff --git a/movies-db-service/movies-db/Cargo.toml b/movies-db-service/movies-db/Cargo.toml index b144f77..438b6a1 100644 --- a/movies-db-service/movies-db/Cargo.toml +++ b/movies-db-service/movies-db/Cargo.toml @@ -20,6 +20,7 @@ serde_json = "1.0" async-trait = "0.1" actix-cors = "0.6" rusqlite = { version = "0.29", features = ["bundled"] } +serde_qs = { version = "0.12", features = ["actix4"]} [dev-dependencies] tempdir = "0.3" diff --git a/movies-db-service/movies-db/src/db/movies_index.rs b/movies-db-service/movies-db/src/db/movies_index.rs index efc8980..a49e0d5 100644 --- a/movies-db-service/movies-db/src/db/movies_index.rs +++ b/movies-db-service/movies-db/src/db/movies_index.rs @@ -165,6 +165,9 @@ pub trait MoviesIndex: Send + Sync { /// # Arguments /// `query` - The query to search for. async fn search_movies(&self, query: MovieSearchQuery) -> Result, Error>; + + /// Returns a list of all tags with the number of movies associated with each tag. + async fn get_tag_list_with_count(&self) -> Result, Error>; } #[cfg(test)] diff --git a/movies-db-service/movies-db/src/db/simple_movies_index.rs b/movies-db-service/movies-db/src/db/simple_movies_index.rs index 6bed2f0..2b13bc9 100644 --- a/movies-db-service/movies-db/src/db/simple_movies_index.rs +++ b/movies-db-service/movies-db/src/db/simple_movies_index.rs @@ -185,6 +185,24 @@ impl MoviesIndex for SimpleMoviesIndex { Ok(movie_ids) } + + async fn get_tag_list_with_count(&self) -> Result, Error> { + info!("Getting tag list with count"); + + let mut tag_map: HashMap = HashMap::new(); + + for movie in self.movies.values() { + for tag in movie.movie.tags.iter() { + let count = tag_map.entry(tag.clone()).or_insert(0); + *count += 1; + } + } + + let mut tag_list: Vec<(String, usize)> = tag_map.into_iter().collect(); + tag_list.sort_unstable_by(|(_, lhs), (_, rhs)| rhs.cmp(lhs)); + + Ok(tag_list) + } } impl SimpleMoviesIndex { diff --git a/movies-db-service/movies-db/src/db/sqlite_movies_index.rs b/movies-db-service/movies-db/src/db/sqlite_movies_index.rs index 165f00d..36eabaf 100644 --- a/movies-db-service/movies-db/src/db/sqlite_movies_index.rs +++ b/movies-db-service/movies-db/src/db/sqlite_movies_index.rs @@ -411,6 +411,28 @@ impl MoviesIndex for SqliteMoviesIndex { async fn search_movies(&self, query: MovieSearchQuery) -> Result, Error> { self.search_movies_impl(query).await } + + async fn get_tag_list_with_count(&self) -> Result, Error> { + let connection = self.connection.lock().await; + + let mut stmt = connection.prepare( + "SELECT tag, COUNT(*) FROM tags GROUP BY tag ORDER BY COUNT(*) DESC, tag ASC", + )?; + + let rows = stmt.query_map([], |row| { + let tag: String = row.get(0)?; + let count: usize = row.get(1)?; + + Ok((tag, count)) + })?; + + let mut tags: Vec<(String, usize)> = Vec::new(); + for row in rows { + tags.push(row?); + } + + Ok(tags) + } } #[cfg(test)] diff --git a/movies-db-service/movies-db/src/service/service_handler.rs b/movies-db-service/movies-db/src/service/service_handler.rs index 7730827..a23875f 100644 --- a/movies-db-service/movies-db/src/service/service_handler.rs +++ b/movies-db-service/movies-db/src/service/service_handler.rs @@ -504,6 +504,9 @@ where } /// Handles the request to show the list of all movies. + /// + /// # Arguments + /// * `query` - The query to search for. pub async fn handle_search_movies(&self, query: MovieSearchQuery) -> Result { let movie_ids = match self.index.read().await.search_movies(query).await { Ok(movie_ids) => movie_ids, @@ -525,6 +528,20 @@ where Ok(web::Json(movies)) } + /// Handles the request to get a list of all tags with the number of movies associated with + /// each tag. + pub async fn handle_get_tags(&self) -> Result { + let tags = match self.index.read().await.get_tag_list_with_count().await { + Ok(tags) => tags, + Err(err) => { + error!("Error getting tags: {}", err); + return Self::handle_error(err); + } + }; + + Ok(web::Json(tags)) + } + /// Handles the given error by translating it into an actix-web error response. /// /// # Arguments diff --git a/movies-db-service/movies-db/src/service/service_impl.rs b/movies-db-service/movies-db/src/service/service_impl.rs index bdb9370..adc407d 100644 --- a/movies-db-service/movies-db/src/service/service_impl.rs +++ b/movies-db-service/movies-db/src/service/service_impl.rs @@ -5,6 +5,7 @@ use actix_multipart::Multipart; use actix_web::{http::header, web, App, HttpServer, Responder, Result}; use log::{debug, error, info, trace}; +use serde_qs::actix::QsQuery; use tokio::sync::{mpsc, RwLock}; use crate::{ @@ -104,6 +105,7 @@ where .route("/movie", web::get().to(Self::handle_get_movie)) .route("/movie", web::delete().to(Self::handle_delete_movie)) .route("/movie/search", web::get().to(Self::handle_search_movie)) + .route("/movie/tags", web::get().to(Self::handle_get_tags)) .route("/movie/file", web::post().to(Self::handle_upload_movie)) .route("/movie/file", web::get().to(Self::handle_download_movie)) .route( @@ -188,7 +190,7 @@ where /// * `query` - The query parameters. async fn handle_search_movie( handler: web::Data>>, - query: web::Query, + query: QsQuery, ) -> Result { debug!("Handling GET /api/v1/movie/search"); trace!("Request query: {:?}", query); @@ -200,6 +202,20 @@ where handler.handle_search_movies(query).await } + /// Handles the GET /api/v1/tags endpoint. + /// + /// # Arguments + /// * `handler` - The service handler. + async fn handle_get_tags( + handler: web::Data>>, + ) -> Result { + debug!("Handling GET /api/v1/movie/tags"); + + let handler = handler.read().await; + + handler.handle_get_tags().await + } + /// Handles the GET /api/v1/movie endpoint. /// /// # Arguments diff --git a/movies-db-ui/src/App.tsx b/movies-db-ui/src/App.tsx index 0435125..db5936e 100644 --- a/movies-db-ui/src/App.tsx +++ b/movies-db-ui/src/App.tsx @@ -1,12 +1,14 @@ import * as React from 'react'; import './App.css'; import AddVideoDialog from './components/AddVideoDialog'; -import { AppBar, Box, Button, Toolbar } from '@mui/material'; +import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material'; import { MovieSubmit } from './service/types'; import { service } from './service/service'; import LoadingDialog, { LoadingDialogProps } from './components/LoadingDialog'; import VideosList from './components/VideosList'; import { createTheme, ThemeProvider } from '@mui/material/styles'; +import AddIcon from '@mui/icons-material/Add'; +import Search from './components/Search'; const theme = createTheme({ palette: { @@ -44,8 +46,34 @@ function App() {
- - + + + + Movie DB + + + + service.setSearchString(s)} /> + + + + diff --git a/movies-db-ui/src/components/Search.tsx b/movies-db-ui/src/components/Search.tsx new file mode 100644 index 0000000..936ce39 --- /dev/null +++ b/movies-db-ui/src/components/Search.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import InputBase from '@mui/material/InputBase'; +import SearchIcon from '@mui/icons-material/Search'; +import { IconButton, MenuItem, Paper } from '@mui/material'; + +export interface SearchProps { + onSearch?: (query: string) => void; +} + +export default function Search(props: SearchProps) { + const [query, setQuery] = React.useState(""); + + const handleSearch = () => { + if (props.onSearch) { + props.onSearch(query); + } + }; + + return ( + + + + + setQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + handleSearch(); + } + }} + /> + handleSearch()}> + + + + ); +} \ No newline at end of file diff --git a/movies-db-ui/src/components/VideoCard.tsx b/movies-db-ui/src/components/VideoCard.tsx index b158d85..78596ad 100644 --- a/movies-db-ui/src/components/VideoCard.tsx +++ b/movies-db-ui/src/components/VideoCard.tsx @@ -57,7 +57,7 @@ export default function VideoCard(props: VideoCardProps): JSX.Element { }; return ( - + @@ -84,9 +84,9 @@ export default function VideoCard(props: VideoCardProps): JSX.Element { - + {movie.tags ? movie.tags.map((tag, index) => { - return + return }) :
}
diff --git a/movies-db-ui/src/components/VideoListFilters.tsx b/movies-db-ui/src/components/VideoListFilters.tsx new file mode 100644 index 0000000..5e93b3f --- /dev/null +++ b/movies-db-ui/src/components/VideoListFilters.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import { Box, Chip, IconButton, Menu, MenuItem } from '@mui/material'; +import IconSort from '@mui/icons-material/SortRounded'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Check from '@mui/icons-material/Check'; +import { SortingField, SortingOrder } from '../service/types'; + +enum SortingOption { + TitleAscending, + TitleDescending, + DateAscending, + DateDescending, +} + +export interface VideoListFilterProps { + tagList: [string, number][]; + onChangeTags?: (tags: string[]) => void; + onChangeSorting?: (sorting_field: SortingField, sorting_order: SortingOrder) => void; +} + +const menuOptions = [ + { + label: "Date descending", + value: SortingOption.DateDescending, + }, + { + label: "Date ascending", + value: SortingOption.DateAscending, + }, + { + label: "Title descending", + value: SortingOption.TitleDescending, + }, + { + label: "Title ascending", + value: SortingOption.TitleAscending, + }, +] + +export default function VideoListFilter(props: VideoListFilterProps) { + const [tags, setTags] = React.useState([]); + const [anchorEl, setAnchorEl] = React.useState(null); + const [sortingOption, setSortingOption] = React.useState(SortingOption.DateDescending); + + const handleToggleTag = (tagName: string) => { + if (tagName === "") { + setTags([]); + return; + } + + const index = tags.indexOf(tagName); + if (index === -1) { + setTags([...tags, tagName]); + } else { + setTags(tags.filter((_, i) => i !== index)); + } + }; + + React.useEffect(() => { + if (props.onChangeTags) { + props.onChangeTags(tags); + } + }, [tags, props]); + + React.useEffect(() => { + const handleSorting = (option: SortingOption) => { + if (!props.onChangeSorting) { + return; + } + + switch (option) { + case SortingOption.DateAscending: + props.onChangeSorting(SortingField.Date, SortingOrder.Ascending); + break; + case SortingOption.DateDescending: + props.onChangeSorting(SortingField.Date, SortingOrder.Descending); + break; + case SortingOption.TitleAscending: + props.onChangeSorting(SortingField.Title, SortingOrder.Ascending); + break; + case SortingOption.TitleDescending: + props.onChangeSorting(SortingField.Title, SortingOrder.Descending); + break; + } + }; + + handleSorting(sortingOption); + }, [sortingOption, props]); + + const handleMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + + + handleToggleTag("")} variant={tags.length === 0 ? 'filled' : 'outlined'} label="All" clickable /> + {props.tagList.map(([tagName, _]) => { + return ( + handleToggleTag(tagName)} variant={tags.indexOf(tagName) === -1 ? 'outlined' : 'filled'} label={tagName} clickable /> + ); + })} + + + {menuOptions.map(option => { + return ( { + setSortingOption(option.value); + handleClose(); + }}> + {option.value === sortingOption ? : <>} + {option.label} + + ) + })} + + + + + + + + ); +} \ No newline at end of file diff --git a/movies-db-ui/src/components/VideoPlayer.tsx b/movies-db-ui/src/components/VideoPlayer.tsx index 6aaec8f..edabff0 100644 --- a/movies-db-ui/src/components/VideoPlayer.tsx +++ b/movies-db-ui/src/components/VideoPlayer.tsx @@ -57,7 +57,7 @@ export default function VideoPlayer(props: VideoPlayerProps) { }}>