1
0
Fork 0
mirror of https://github.com/sussy-code/smov.git synced 2024-12-20 14:37:43 +01:00
This commit is contained in:
mrjvs 2023-08-20 17:59:46 +02:00
parent eb57f1958f
commit 1fde44076a
19 changed files with 327 additions and 315 deletions

View file

@ -1,4 +1,8 @@
import c from "classnames";
import { useState } from "react";
import { MWQuery } from "@/backend/metadata/types/mw";
import { Flare } from "@/components/utils/Flare";
import { Icon, Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl";
@ -11,6 +15,8 @@ export interface SearchBarProps {
}
export function SearchBarInput(props: SearchBarProps) {
const [focused, setFocused] = useState(false);
function setSearch(value: string) {
props.onChange(
{
@ -22,18 +28,42 @@ export function SearchBarInput(props: SearchBarProps) {
}
return (
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
<Flare.Base
className={c({
"hover:flare-enabled group relative flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center":
true,
"bg-search-background": !focused,
"bg-search-focused": focused,
})}
>
<Flare.Light
flareSize={400}
enabled={focused}
className="rounded-[28px]"
backgroundClass={c({
"transition-colors": true,
"bg-search-background": !focused,
"bg-search-focused": focused,
})}
/>
<Flare.Child className="flex flex-1 flex-col">
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon">
<Icon icon={Icons.SEARCH} />
</div>
<TextInputControl
onUnFocus={props.onUnFocus}
onUnFocus={() => {
setFocused(false);
props.onUnFocus();
}}
onFocus={() => setFocused(true)}
onChange={(val) => setSearch(val)}
value={props.value.searchQuery}
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
className="text-search-text w-full flex-1 bg-transparent px-4 py-4 pl-12 placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2"
placeholder={props.placeholder}
/>
</div>
</Flare.Child>
</Flare.Base>
);
}

View file

@ -22,6 +22,15 @@ function FooterLink(props: {
);
}
function Dmca() {
const { t } = useTranslation();
return (
<FooterLink icon={Icons.DRAGON} href="https://youtu.be/-WOonkg_ZCo">
{t("footer.links.dmca")}
</FooterLink>
);
}
export function Footer() {
const { t } = useTranslation();
@ -33,28 +42,26 @@ export function Footer() {
<BrandPill />
</div>
<p className="mt-4 lg:max-w-[400px]">{t("footer.tagline")}</p>
<div className="mt-8 space-x-[2rem]">
<FooterLink icon={Icons.GITHUB} href="https://github.com/movie-web">
{t("footer.links.github")}
</FooterLink>
<FooterLink
icon={Icons.DISCORD}
href="https://github.com/movie-web"
>
{t("footer.links.github")}
</FooterLink>
</div>
</div>
<div className="md:text-right">
<h3 className="font-semibold text-type-emphasis">
{t("footer.legal.disclaimer")}
</h3>
<p className="mt-3">{t("footer.legal.disclaimerText")}</p>
<div className="mt-8">
<FooterLink icon={Icons.DRAGON} href="https://youtu.be/-WOonkg_ZCo">
{t("footer.links.dmca")}
</FooterLink>
</div>
<div className="space-x-[2rem]">
<FooterLink icon={Icons.GITHUB} href="https://github.com/movie-web">
{t("footer.links.github")}
</FooterLink>
<FooterLink icon={Icons.DISCORD} href="https://discord.movie-web.app">
{t("footer.links.discord")}
</FooterLink>
<div className="inline md:hidden">
<Dmca />
</div>
</div>
<div className="hidden items-center justify-end md:flex">
<Dmca />
</div>
</WideContainer>
</footer>

View file

@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Lightbar } from "@/components/utils/Lightbar";
import { useBannerSize } from "@/hooks/useBanner";
import { conf } from "@/setup/config";
import SettingsModal from "@/views/SettingsModal";
@ -18,8 +19,14 @@ export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
const [showModal, setShowModal] = useState(false);
return (
<>
<div className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center">
<div className="absolute inset-x-0 flex items-center">
<Lightbar />
</div>
</div>
<div
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
className="fixed left-0 right-0 top-0 min-h-[150px] bg-gradient-to-b from-background-main via-background-main to-transparent sm:from-transparent"
style={{
top: `${bannerHeight}px`,
}}
@ -28,9 +35,9 @@ export function Navigation(props: NavigationProps) {
<div
className={`${
props.bg ? "opacity-100" : "opacity-0"
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
} absolute inset-0 block bg-background-main transition-opacity duration-300`}
>
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
</div>
<div className="relative flex w-full items-center justify-center sm:w-fit">
<div className="mr-auto sm:mr-6">
@ -73,5 +80,6 @@ export function Navigation(props: NavigationProps) {
</div>
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
</div>
</>
);
}

View file

@ -1,6 +1,7 @@
export interface TextInputControlPropsNoLabel {
onChange?: (data: string) => void;
onUnFocus?: () => void;
onFocus?: () => void;
value?: string;
placeholder?: string;
className?: string;
@ -17,6 +18,7 @@ export function TextInputControl({
label,
className,
placeholder,
onFocus,
}: TextInputControlProps) {
const input = (
<input
@ -26,6 +28,7 @@ export function TextInputControl({
onChange={(e) => onChange && onChange(e.target.value)}
value={value}
onBlur={() => onUnFocus && onUnFocus()}
onFocus={() => onFocus?.()}
/>
);

View file

@ -0,0 +1,7 @@
.flare-enabled .flare-light {
opacity: 1 !important;
}
.hover\:flare-enabled:hover .flare-light {
opacity: 1 !important;
}

View file

@ -1,5 +1,6 @@
import c from "classnames";
import { useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef } from "react";
import "./Flare.css";
export interface FlareProps {
className?: string;
@ -12,7 +13,15 @@ export interface FlareProps {
const SIZE_DEFAULT = 200;
const CSS_VAR_DEFAULT = "--colors-global-accentA";
export function Flare(props: FlareProps) {
function Base(props: { className?: string; children?: ReactNode }) {
return <div className={c(props.className, "relative")}>{props.children}</div>;
}
function Child(props: { className?: string; children?: ReactNode }) {
return <div className={c(props.className, "relative")}>{props.children}</div>;
}
function Light(props: FlareProps) {
const outerRef = useRef<HTMLDivElement>(null);
const size = props.flareSize ?? SIZE_DEFAULT;
const cssVar = props.cssColorVar ?? CSS_VAR_DEFAULT;
@ -20,13 +29,14 @@ export function Flare(props: FlareProps) {
useEffect(() => {
function mouseMove(e: MouseEvent) {
if (!outerRef.current) return;
const rect = outerRef.current.getBoundingClientRect();
outerRef.current.style.setProperty(
"--bg-x",
`${(e.clientX - size / 2).toFixed(0)}px`
`${(e.clientX - rect.left - size / 2).toFixed(0)}px`
);
outerRef.current.style.setProperty(
"--bg-y",
`${(e.clientY - size / 2).toFixed(0)}px`
`${(e.clientY - rect.top - size / 2).toFixed(0)}px`
);
}
document.addEventListener("mousemove", mouseMove);
@ -38,10 +48,10 @@ export function Flare(props: FlareProps) {
<div
ref={outerRef}
className={c(
"overflow-hidden, pointer-events-none absolute inset-0 hidden",
"flare-light pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-[400ms]",
props.className,
{
"!block": props.enabled ?? false,
"!opacity-100": props.enabled ?? false,
}
)}
style={{
@ -49,7 +59,7 @@ export function Flare(props: FlareProps) {
backgroundPosition: `var(--bg-x) var(--bg-y)`,
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
backgroundSize: "200px 200px",
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
}}
>
<div
@ -60,16 +70,22 @@ export function Flare(props: FlareProps) {
)}
>
<div
className="absolute inset-0 opacity-5"
className="absolute inset-0 opacity-10"
style={{
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
backgroundPosition: `var(--bg-x) var(--bg-y)`,
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
backgroundSize: "200px 200px",
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
}}
/>
</div>
</div>
);
}
export const Flare = {
Base,
Light,
Child,
};

View file

@ -0,0 +1,22 @@
.lightbar {
position: absolute;
left: -25vw;
top: 0;
width: 150vw;
height: 800px;
pointer-events: none;
user-select: none;
--top: theme('colors.background.main');
--bottom: theme('colors.lightBar.light');
--first: conic-gradient(from 90deg at 80% 50%,var(--top),var(--bottom));
--second: conic-gradient(from 270deg at 20% 50%,var(--bottom),var(--top));
mask-image: radial-gradient(100% 50% at center center, black, transparent);
background-image: var(--first), var(--second);
background-position-x: 1%, 99%;
background-position-y: 0%, 0%;
background-size: 50% 100%, 50% 100%;
opacity: 1;
transform: rotate(180deg) translateZ(0px) translateY(400px);
transform-origin: center center;
background-repeat: no-repeat;
}

View file

@ -0,0 +1,9 @@
import "./Lightbar.css";
export function Lightbar(props: { className?: string }) {
return (
<div className={props.className}>
<div className="lightbar" />
</div>
);
}

View file

@ -17,7 +17,6 @@ import { SettingsProvider } from "@/state/settings";
import { WatchedContextProvider } from "@/state/watched";
import { MediaView } from "@/views/media/MediaView";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { V2MigrationView } from "@/views/other/v2Migration";
import { SearchView } from "@/views/search/SearchView";
function LegacyUrlView({ children }: { children: ReactElement }) {
@ -62,7 +61,6 @@ function App() {
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/s/:query">
<QuickSearch />
</Route>

View file

@ -4,9 +4,10 @@
html,
body {
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
@apply bg-background-main font-open-sans text-denim-700 overflow-x-hidden;
min-height: 100vh;
min-height: 100dvh;
position: relative;
}
html[data-full],

View file

@ -10,7 +10,7 @@
"headingTitle": "Search results",
"bookmarks": "Bookmarks",
"continueWatching": "Continue Watching",
"title": "What do you want to watch?",
"title": "What to watch tonight?",
"placeholder": "What do you want to watch?"
},
"media": {
@ -136,7 +136,8 @@
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
"links": {
"github": "GitHub",
"dmca": "DMCA"
"dmca": "DMCA",
"discord": "Discord"
},
"legal": {
"disclaimer": "Disclaimer",

0
src/views/HomePage.tsx Normal file
View file

0
src/views/SearchPart.tsx Normal file
View file

View file

@ -1,107 +0,0 @@
import pako from "pako";
import { useEffect, useState } from "react";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { conf } from "@/setup/config";
function fromBinary(str: string): Uint8Array {
const result = new Uint8Array(str.length);
[...str].forEach((char, i) => {
result[i] = char.charCodeAt(0);
});
return result;
}
export function importV2Data({ data, time }: { data: any; time: Date }) {
const savedTime = localStorage.getItem("mw-migration-date");
if (savedTime) {
if (new Date(savedTime) >= time) {
// has already migrated this or something newer, skip
return false;
}
}
// restore migration data
if (data.bookmarks)
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks));
if (data.videoProgress)
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress));
localStorage.setItem("mw-migration-date", time.toISOString());
return true;
}
export function EmbedMigration() {
let hasReceivedMigrationData = false;
const onMessage = (e: any) => {
const data = e.data;
if (data && data.isMigrationData && !hasReceivedMigrationData) {
hasReceivedMigrationData = true;
const didImport = importV2Data({
data: data.data,
time: data.date,
});
if (didImport) window.location.reload();
}
};
useEffect(() => {
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
});
return <iframe src="https://movie.squeezebox.dev" hidden />;
}
export function V2MigrationView() {
const [done, setDone] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search ?? "");
if (!params.has("m-time") || !params.has("m-data")) {
// migration params missing, just redirect
setDone(true);
return;
}
const data = JSON.parse(
pako.inflate(fromBinary(atob(params.get("m-data") as string)), {
to: "string",
})
);
const timeOfMigration = new Date(params.get("m-time") as string);
importV2Data({
data,
time: timeOfMigration,
});
// finished
setDone(true);
}, []);
// redirect when done
useEffect(() => {
if (!done) return;
const newUrl = new URL(window.location.href);
const newParams = [] as string[];
newUrl.searchParams.forEach((_, key) => newParams.push(key));
newParams.forEach((v) => newUrl.searchParams.delete(v));
newUrl.searchParams.append("migrated", "1");
// hash router compatibility
newUrl.hash = conf().NORMAL_ROUTER ? "" : `/search/${MWMediaType.MOVIE}`;
newUrl.pathname = conf().NORMAL_ROUTER
? `/search/${MWMediaType.MOVIE}`
: "";
window.location.href = newUrl.toString();
}, [done]);
return null;
}

View file

@ -0,0 +1,58 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useBookmarkContext } from "@/state/bookmark";
import { useWatchedContext } from "@/state/watched";
export function BookmarksPart() {
const { t } = useTranslation();
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
const bookmarks = getFilteredBookmarks();
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const { watched } = useWatchedContext();
const bookmarksSorted = useMemo(() => {
return bookmarks
.map((v) => {
return {
...v,
watched: watched.items
.sort((a, b) => b.watchedAt - a.watchedAt)
.find((watchedItem) => watchedItem.item.meta.id === v.id),
};
})
.sort(
(a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0)
);
}, [watched.items, bookmarks]);
if (bookmarks.length === 0) return null;
return (
<div>
<SectionHeading
title={t("search.bookmarks") || "Bookmarks"}
icon={Icons.BOOKMARK}
>
<EditButton editing={editing} onEdit={setEditing} />
</SectionHeading>
<MediaGrid ref={gridRef}>
{bookmarksSorted.map((v) => (
<WatchedMediaCard
key={v.id}
media={v}
closable={editing}
onClose={() => setItemBookmark(v, false)}
/>
))}
</MediaGrid>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
getIfBookmarkedFromPortable,
useBookmarkContext,
} from "@/state/bookmark";
import { useWatchedContext } from "@/state/watched";
function Watched() {
const { t } = useTranslation();
const { getFilteredBookmarks } = useBookmarkContext();
const { getFilteredWatched, removeProgress } = useWatchedContext();
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const bookmarks = getFilteredBookmarks();
const watchedItems = getFilteredWatched().filter(
(v) => !getIfBookmarkedFromPortable(bookmarks, v.item.meta)
);
if (watchedItems.length === 0) return null;
return (
<div>
<SectionHeading
title={t("search.continueWatching") || "Continue Watching"}
icon={Icons.CLOCK}
>
<EditButton editing={editing} onEdit={setEditing} />
</SectionHeading>
<MediaGrid ref={gridRef}>
{watchedItems.map((v) => (
<WatchedMediaCard
key={v.item.meta.id}
media={v.item.meta}
closable={editing}
onClose={() => removeProgress(v.item.meta.id)}
/>
))}
</MediaGrid>
</div>
);
}

View file

@ -1,12 +1,9 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/Button";
import { EditButton } from "@/components/buttons/EditButton";
import { Icons } from "@/components/Icon";
import { Modal, ModalCard } from "@/components/layout/Modal";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
@ -16,8 +13,6 @@ import {
} from "@/state/bookmark";
import { useWatchedContext } from "@/state/watched";
import { EmbedMigration } from "../other/v2Migration";
function Bookmarks() {
const { t } = useTranslation();
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
@ -101,106 +96,9 @@ function Watched() {
);
}
function NewDomainModal() {
const [show, setShow] = useState(
new URLSearchParams(window.location.search).get("migrated") === "1" ||
localStorage.getItem("mw-show-domain-modal") === "true"
);
const [loaded, setLoaded] = useState(false);
const history = useHistory();
const { t } = useTranslation();
const closeModal = useCallback(() => {
localStorage.setItem("mw-show-domain-modal", "false");
setShow(false);
}, []);
useEffect(() => {
const newParams = new URLSearchParams(history.location.search);
newParams.delete("migrated");
if (newParams.get("migrated") === "1")
localStorage.setItem("mw-show-domain-modal", "true");
history.replace({
search: newParams.toString(),
});
}, [history]);
useEffect(() => {
setTimeout(() => {
setLoaded(true);
}, 500);
}, []);
// If you see this bit of code, don't snitch!
// We need to urge users to update their bookmarks and usage,
// so we're putting a fake deadline that's only 2 weeks away.
const day = 1e3 * 60 * 60 * 24;
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const firstVisitToSite = new Date(
localStorage.getItem("firstVisitToSite") || Date.now()
);
localStorage.setItem("firstVisitToSite", firstVisitToSite.toISOString());
const fakeEndResult = new Date(firstVisitToSite.getTime() + 14 * day);
const endDateString = `${fakeEndResult.getDate()} ${
months[fakeEndResult.getMonth()]
} ${fakeEndResult.getFullYear()}`;
return (
<Modal show={show && loaded}>
<ModalCard>
<div className="mb-12">
<div
className="absolute left-0 top-0 h-[300px] w-full -translate-y-1/2 opacity-50"
style={{
backgroundImage: `radial-gradient(ellipse 70% 9rem, #7831C1 0%, transparent 100%)`,
}}
/>
<div className="relative flex items-center justify-center">
<div className="rounded-full bg-bink-200 px-12 py-4 text-center text-sm font-bold text-white md:text-xl">
{t("v3.newDomain")}
</div>
</div>
</div>
<div className="space-y-6">
<h2 className="text-2xl font-bold text-white">
{t("v3.newSiteTitle")}
</h2>
<p className="leading-7">
<Trans i18nKey="v3.newDomainText" values={{ date: endDateString }}>
<span className="text-slate-300" />
<span className="font-bold text-white" />
</Trans>
</p>
<p>{t("v3.tireless")}</p>
</div>
<div className="mb-6 mt-16 flex items-center justify-center">
<Button icon={Icons.PLAY} onClick={() => closeModal()}>
{t("v3.leaveAnnouncement")}
</Button>
</div>
</ModalCard>
</Modal>
);
}
export function HomeView() {
return (
<div>
<EmbedMigration />
<NewDomainModal />
<Bookmarks />
<Watched />
</div>

View file

@ -27,16 +27,13 @@ export function SearchView() {
return (
<FooterView>
<Navigation bg={showBg} />
<div className="relative z-10 mb-16 sm:mb-24">
<Helmet>
<title>{t("global.name")}</title>
</Helmet>
<Navigation bg={showBg} />
<ThinContainer>
<div className="mt-44 space-y-16 text-center">
<div className="absolute bottom-0 left-0 right-0 flex h-0 justify-center">
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" />
</div>
<div className="relative z-10 mb-16">
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
</div>

View file

@ -50,20 +50,39 @@ module.exports = {
defaultTheme: {
extend: {
colors: {
// meta data for the theme itself
global: {
accentA: "#505DBD",
accentB: "#3440A1"
},
// light bar
lightBar: {
light: "#2A2A71"
},
// only used for body colors/textures
background: {
main: "#0A0A10",
accentA: "#6E3B80",
accentB: "#1F1F50"
},
global: {
accentA: "#505DBD",
accentB: "#3440A1"
},
// typography
type: {
emphasis: "#FFFFFF",
text: "#73739D",
dimmed: "#926CAD",
divider: "#353549"
divider: "#262632"
},
// search bar
search: {
background: "#1E1E33",
focused: "#24243C",
placeholder: "#4A4A71",
icon: "#545476",
text: "#FFFFFF"
}
}
}