diff --git a/src/api/pref/History.tsx b/src/api/pref/History.tsx index 979532a..9e7c969 100644 --- a/src/api/pref/History.tsx +++ b/src/api/pref/History.tsx @@ -1,10 +1,8 @@ import { useLocalStorage } from "@mantine/hooks"; import { usePreference } from "./Preferences"; -export type HistoryItem = { - t: "v" | "s", - d: string, -}; +export type PartialHistoryItem = ["v" | "s", string]; +export type HistoryItem = ["v" | "s", string, number]; export const useNekoTubeHistory = () => { const useLocalHistory = usePreference("useLocalHistory"); @@ -16,10 +14,26 @@ export const useNekoTubeHistory = () => { return { history: value, setHistory: setValue, - add: (item: HistoryItem) => { + add: (partial: PartialHistoryItem) => { if(!useLocalHistory) return; - setValue(v => [...v, item]); + let item = [...partial, Date.now()] as HistoryItem; + + setValue(v => { + if(item[0] == "s") { + let contains = v.some(([t, d]) => t == "s" && d == item[1]); + + if (contains) + return v.map(([t, d, ts]) => (t == "s" && d == item[1]) ? [t, d, Date.now()] : [t, d, ts]); + else + return [...v, item]; + } else { + let last = v[v.length - 1]; + + if(last && last[0] == "v" && last[1] == item[1]) return v.map((item, idx, arr) => (idx == (arr.length - 1)) ? [item[0], item[1], Date.now()] : item); + return [...v, item]; + } + }); }, clear: () => setValue([]), }; diff --git a/src/components/cards/VideoCard.tsx b/src/components/cards/VideoCard.tsx index 3ea6418..e0e76c1 100644 --- a/src/components/cards/VideoCard.tsx +++ b/src/components/cards/VideoCard.tsx @@ -1,4 +1,4 @@ -import { Grid, Group, Paper, Stack, Text, Title } from "@mantine/core"; +import { Box, Grid, Group, Loader, Paper, Stack, Text, Title } from "@mantine/core"; import { ThumbnailRender } from "./ThumbnailRender"; import { VideoInfo } from "../../api/types/video"; import { Link } from "react-router-dom"; @@ -10,6 +10,46 @@ import { useContext, useEffect, useState } from "react"; import { usePreference } from "../../api/pref/Preferences"; import { APIContext } from "../../api/provider/APIController"; +export const FetchVideoWrapper = ({ + id, + component: Component, +}: { + id: string, + component: ({ video }: { video: VideoInfo }) => any, +}) => { + const { api } = useContext(APIContext); + const [video, setVideo] = useState(null); + + useEffect(() => { + (async () => { + setVideo(await api.getVideoInfo(id)); + })() + }, []); + + return ( + + {video ? ( + + ) : ( + + + + + Fetching video + + + {id} + + + + )} + + ) +}; + export const HorizontalVideoCard = ({ video: originalVideo, }: { diff --git a/src/components/options/OptionsContext.tsx b/src/components/options/OptionsContext.tsx index 4f074b9..1bc8373 100644 --- a/src/components/options/OptionsContext.tsx +++ b/src/components/options/OptionsContext.tsx @@ -5,7 +5,7 @@ import { Button, Drawer, Group, ScrollArea, Text } from "@mantine/core"; import { OptionsRouter } from "./OptionsRouter"; import { IconArrowLeft } from "@tabler/icons-react"; -export type OptionsView = "main" | "instanceSelect" | "openWith" | "formatSelect"; +export type OptionsView = "main" | "instanceSelect" | "openWith" | "formatSelect" | "history"; export interface OptionsContextAPI { opened: boolean; @@ -56,6 +56,10 @@ export const OptionsProvider = ({ children }: React.PropsWithChildren) => { useHotkeys([ ["Ctrl + o", () => toggle()], + ["Ctrl + h", () => { + open(); + setView("history"); + }], ], [], true); return ( @@ -101,6 +105,7 @@ export const OptionsProvider = ({ children }: React.PropsWithChildren) => { formatSelect: "Select Format", instanceSelect: "Select Instance", openWith: "Open with...", + history: "History", } as Record)[view]} diff --git a/src/components/options/OptionsRouter.tsx b/src/components/options/OptionsRouter.tsx index cf2a261..ab8ba9e 100644 --- a/src/components/options/OptionsRouter.tsx +++ b/src/components/options/OptionsRouter.tsx @@ -5,6 +5,7 @@ import { OptionsMainView } from "./views/MainView"; import { OptionsInstanceView } from "./views/InstanceView"; import { OptionsOpenWithView } from "./views/OpenWith"; import { OptionsFormatView } from "./views/FormatView"; +import { OptionsHistoryView } from "./views/HistoryView"; export const OptionsRouter = () => { const { view } = useContext(OptionsContext); @@ -15,6 +16,7 @@ export const OptionsRouter = () => { {view == "instanceSelect" && } {view == "openWith" && } {view == "formatSelect" && } + {view == "history" && } ); }; diff --git a/src/components/options/views/HistoryView.tsx b/src/components/options/views/HistoryView.tsx new file mode 100644 index 0000000..0e2ed8d --- /dev/null +++ b/src/components/options/views/HistoryView.tsx @@ -0,0 +1,99 @@ +import React, { useContext, useState } from "react"; +import { VideoPlayerContext } from "../../../api/player/VideoPlayerContext"; +import { ActionIcon, Button, Divider, Grid, Group, Paper, SegmentedControl, Space, Stack, Text, Tooltip } from "@mantine/core"; +import { InstanceSelect } from "../comps/InstanceSelect"; +import { FormatSelect } from "../comps/FormatSelect"; +import { PlaybackSpeed } from "../comps/PlaybackSpeed"; +import { PreferencesList } from "../comps/PreferencesList"; +import { DebuggingSection } from "../comps/DebuggingSection"; +import { OpenWithButton } from "../links/OpenWithButton"; +import { LoopVideo } from "../comps/LoopVideo"; +import { ThemeSection } from "../comps/ThemeSection"; +import { FlavorOption } from "../comps/Flavor"; +import { HistoryItem, useNekoTubeHistory } from "../../../api/pref/History"; +import { IconSearch, IconTrash } from "@tabler/icons-react"; +import { FetchVideoWrapper, HorizontalVideoCard } from "../../cards/VideoCard"; +import { DateCard } from "../../cards/DateCard"; + +export const OptionsHistoryView = () => { + const history = useNekoTubeHistory(); + const [showTypes, setShowTypes] = useState<"*" | "s" | "v">("*"); + + return ( + + + + + + + + + + + Filter by: + + setShowTypes(v)} + /> + + + {history.history + .sort(([_, __, a], [___, ____, b]) => b-a) + .filter(([t]) => showTypes == "*" || t == showTypes) + .map(([type, data, ts], idx) => ( + + {({ + s: () => ( + + + + + {data} + + + + ), + v: () => ( + + ), + } as Record React.ReactNode>)[type]()} + + + + + { + history.setHistory(v => v + .filter(([a, b, c]) => !((a == type && b == data) && (ts == c)))) + }} + > + + + + + + + ))} + + + + ); +}; diff --git a/src/components/options/views/MainView.tsx b/src/components/options/views/MainView.tsx index 39f275d..7795a60 100644 --- a/src/components/options/views/MainView.tsx +++ b/src/components/options/views/MainView.tsx @@ -1,6 +1,6 @@ import { useContext, useState } from "react"; import { VideoPlayerContext } from "../../../api/player/VideoPlayerContext"; -import { Divider, Grid, Space, Stack, Text } from "@mantine/core"; +import { Button, Divider, Grid, Space, Stack, Text } from "@mantine/core"; import { InstanceSelect } from "../comps/InstanceSelect"; import { FormatSelect } from "../comps/FormatSelect"; import { PlaybackSpeed } from "../comps/PlaybackSpeed"; @@ -10,9 +10,12 @@ import { OpenWithButton } from "../links/OpenWithButton"; import { LoopVideo } from "../comps/LoopVideo"; import { ThemeSection } from "../comps/ThemeSection"; import { FlavorOption } from "../comps/Flavor"; +import { OptionsContext } from "../OptionsContext"; +import { IconClock } from "@tabler/icons-react"; export const OptionsMainView = () => { const { videoInfo } = useContext(VideoPlayerContext); + const { setView } = useContext(OptionsContext); const loaded = !!videoInfo; @@ -34,6 +37,15 @@ export const OptionsMainView = () => { + + diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx index dfb23a7..3fc7fab 100644 --- a/src/components/search/SearchBar.tsx +++ b/src/components/search/SearchBar.tsx @@ -1,24 +1,32 @@ -import { ActionIcon, Box, Button, Combobox, Grid, Group, Loader, Stack, Text, TextInput, useCombobox } from "@mantine/core"; +import { ActionIcon, Box, Button, Combobox, Grid, Group, Loader, Stack, Text, TextInput, Tooltip, useCombobox } from "@mantine/core"; import { useContext, useEffect, useRef, useState } from "react"; import { APIContext } from "../../api/provider/APIController"; -import { IconAlertTriangle, IconPencil, IconSearch } from "@tabler/icons-react"; +import { IconAlertTriangle, IconClock, IconPencil, IconSearch } from "@tabler/icons-react"; import { useLocation, useNavigate, useNavigation, useSearchParams } from "react-router-dom"; import { useKeyboardSfx } from "../../hooks/useSoundEffect"; import { useHotkeys } from "@mantine/hooks"; import { parseSearchShortcut, SearchShortcut, SearchShortcutRenderer, shortcutToLocation } from "./SearchShortcut"; import { useIsMobile } from "../../hooks/useIsMobile"; import { highlightSearch } from "../../utils/highlightSearch"; +import { useNekoTubeHistory } from "../../api/pref/History"; + +interface SearchSuggestion { + text: string; + type: "api" | "history"; + index?: number; +} export const SearchBar = () => { const isMobile = useIsMobile(); const { api } = useContext(APIContext); + const history = useNekoTubeHistory(); const navigate = useNavigate(); const location = useLocation(); const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption(), }); const [loading, setLoading] = useState(false); - const [suggestions, setSuggestions] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [errorMessage, setErrorMessage] = useState(null); const [value, setValue] = useState((location.pathname == "/search" ? (new URLSearchParams(location.search).get("q") || "") : "")); const [pickedSuggestion, setPickedSuggestion] = useState(""); @@ -31,11 +39,11 @@ export const SearchBar = () => { ["ctrl + s", () => ref.current.focus()], ], [], true); - const options = (suggestions || []).map((item) => ( - + const options = (suggestions || []).map(({ text, type }, i) => ( + - {highlightSearch(value, item).map(({ + {highlightSearch(value, text).map(({ text, highlight, }, i) => ( @@ -44,6 +52,21 @@ export const SearchBar = () => { ))} + {type == "history" && ( + + { + e.stopPropagation(); + history.setHistory(v => v.filter(([t, d]) => t !== "s" && d !== text)); + fetchSuggestions(value); + }} + > + + + + )} {isMobile && ( { onClick={(e) => { e.stopPropagation(); - setValue(item); - fetchSuggestions(item); + setValue(text); + fetchSuggestions(text); }} > @@ -76,8 +99,21 @@ export const SearchBar = () => { setLoading(true); setErrorMessage(null); try { - let sugs = await api.searchSuggestions(query, abortController.current.signal); - setSuggestions(sugs); + let apiSuggestions = (await api.searchSuggestions(query, abortController.current.signal)) + .map(s => ({ text: s, type: "api" as const })); + + let historySuggestions = history.history + .filter(([t]) => t == "s") + .sort(([_, __, a], [___, ____, b]) => a - b) + .map(([_, text]) => text) + .filter(text => text.toLowerCase().includes(query.toLowerCase())) + .map(text => ({ text, type: "history" as const })); + + setSuggestions([ + ...historySuggestions, + ...apiSuggestions, + ]); + abortController.current = null; setLoading(false); } catch (e) { @@ -140,7 +176,7 @@ export const SearchBar = () => { } }} onKeyUp={(e) => { - setPickedSuggestion(suggestions[combobox.getSelectedOptionIndex()]) + setPickedSuggestion(suggestions[combobox.getSelectedOptionIndex()]?.text) }} rightSection={loading && } /> @@ -148,7 +184,6 @@ export const SearchBar = () => { { search(value); diff --git a/src/site/Root.tsx b/src/site/Root.tsx index a941ae7..c1a59dd 100644 --- a/src/site/Root.tsx +++ b/src/site/Root.tsx @@ -8,12 +8,14 @@ import { SearchBar } from "../components/search/SearchBar"; import { VideoPlayerContext } from "../api/player/VideoPlayerContext"; import { useDocumentTitle, useFullscreen } from "@mantine/hooks"; import { TabsContext } from "../components/tabs/TabsContext"; +import { useNekoTubeHistory } from "../api/pref/History"; export const Root = () => { const { setVideoID, videoInfo, muted, playState } = useContext(VideoPlayerContext); const { isTabsVisible } = useContext(TabsContext); const { fullscreen } = useFullscreen(); const [searchParams] = useSearchParams(); + const history = useNekoTubeHistory(); const location = useLocation(); const { state } = useNavigation(); @@ -26,19 +28,23 @@ export const Root = () => { } }, [isNavigating]); - const v = searchParams.get("v"); - useEffect(() => { - setVideoID(v); - }, [v]); - useDocumentTitle({ - "/": "Home - NekoTube", - "/search": `πŸ”Ž ${searchParams.get("q")} - NekoTube`, + "/": "Home | NekoTube", + "/search": `πŸ”Ž ${searchParams.get("q")} | NekoTube`, "/watch": videoInfo ? ( - `${(playState == "paused" ? "⏸︎ " : (muted ? "πŸ”‡ " : "")) + videoInfo.title} - NekoTube` - ) : "Loading... - NekoTube", + `${(playState == "paused" ? "⏸︎ " : (muted ? "πŸ”‡ " : "")) + videoInfo.title} | NekoTube` + ) : "Loading... | NekoTube", }[location?.pathname] || "NekoTube"); + useEffect(() => { + console.log("changed") + if(location.pathname == "/search") history.add(["s", searchParams.get("q")]); + if(location.pathname == "/watch") { + history.add(["v", searchParams.get("v")]); + setVideoID(searchParams.get("v")); + } + }, [location.pathname, location.search]); + return (