complete basic functionalities (#1)

Questa PR include il layout e le funzionalità di base (ricerca e visione video SD/HD).

Reviewed-on: #1
Co-authored-by: lamacchinadesiderante <lamacchinadesiderante@tuta.io>
Co-committed-by: lamacchinadesiderante <lamacchinadesiderante@tuta.io>
This commit is contained in:
lamacchinadesiderante 2024-04-24 17:28:47 +00:00 committed by lamacchinadesiderante
parent 04a4684f6f
commit 607acbb875
56 changed files with 2135 additions and 476 deletions

22
locale/en.json Normal file
View File

@ -0,0 +1,22 @@
{
"Header": {
"title": "Proxy Raye",
"description": "A proxy for XVideos",
"disclaimer_0": "Genital sexuality is only one of the many possible conceptions of sexuality",
"disclaimer_1": "Platform capitalism makes money on desire flow. Proxies avoid this to happen.",
"disclaimer_2": "Platform capitalism is narcissism-driven",
"disclaimer_3": "Pornhub annoying elements (like ads) are put there intentionally to make you upgrade to premium",
"disclaimer_4": "You're going to masturbate on someone else's imaginary",
"disclaimer_5": "No banners or annoying popups. You can jerk off with no hassle!",
"disclaimer_6": "You're choosing image over imagination. What if they're not in antithesis?"
},
"Search": {
"placeholder": "categories, pornostars, etc...",
"submit": "Search"
},
"Results": {
"query": "Search results for: {{ query }}",
"toggle": "Show preview",
"noData": "No videos found :("
}
}

22
locale/it.json Normal file
View File

@ -0,0 +1,22 @@
{
"Header": {
"title": "Proxy Raye",
"description": "Un proxy per XVideos",
"disclaimer_0": "Quella genitale è solo una delle possibili concezioni della sessualità.",
"disclaimer_1": "Le piattaforme monetizzano i flussi di desiderio. I proxy impediscono che questo accada.",
"disclaimer_2": "Le piattaforme si alimentano del narcisisismo degli utenti.",
"disclaimer_3": "Gli elementi di disturbo su PornHub sono messi lì a posta per farti passare alla versione Premium.",
"disclaimer_4": "Stai per masturbarti sull'immaginario di qualcun altro.",
"disclaimer_5": "Niente banner o popup fastidiosi. Puoi masturbarti in santa pace.",
"disclaimer_6": "Stai preferendo l'immagine all'immaginazione. E se immagine e immaginazione non fossero in antitesi?"
},
"Search": {
"placeholder": "categorie, pornostar, ecc...",
"submit": "Cerca"
},
"Results": {
"query": "Risultati della ricerca per: {{ query }}",
"toggle": "Mostra anteprime risultati",
"noData": "Nessun video trovato :("
}
}

11
next.config.js Normal file
View File

@ -0,0 +1,11 @@
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin();
const path = require('path')
module.exports = withNextIntl({
sassOptions: {
includePaths: [path.join(__dirname, 'src/styles')],
},
})

View File

@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

891
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,16 +9,24 @@
"lint": "next lint"
},
"dependencies": {
"@picocss/pico": "^2.0.6",
"axios": "^1.6.8",
"cheerio": "^1.0.0-rc.12",
"classnames": "^2.5.1",
"next": "14.2.2",
"next-intl": "^3.11.3",
"react": "^18",
"react-dom": "^18",
"next": "14.2.2"
"video.js": "^8.10.0",
"videojs-hls-quality-selector": "^2.0.0"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.2"
"eslint-config-next": "14.2.2",
"sass": "^1.75.0",
"typescript": "^5"
}
}

View File

@ -0,0 +1,20 @@
import "@/styles/globals.scss"
export default function RootLayout({
children,
params: {locale}
}: Readonly<{
children: React.ReactNode;
params: {locale: string};
}>) {
return (
<html lang={locale}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Proxy Raye: un proxy per XVideos basato su PornInvidious</title>
</head>
<body>{children}</body>
</html>
);
}

20
src/app/[locale]/page.tsx Normal file
View File

@ -0,0 +1,20 @@
import axios from 'axios';
import * as cheerio from "cheerio";
import Layout from "@/components/Layout";
import Home from "@/components/Pages/Home";
import { fetchGalleryData } from '@/utils/scrape/gallery';
export default async function HomePage() {
const data = await fetchGalleryData()
return (
<Layout>
<Home data={data} />
</Layout>
);
}

View File

@ -0,0 +1,14 @@
import Layout from "@/components/Layout";
import Search from "@/components/Pages/Search";
import { fetchGalleryData } from "@/utils/scrape/gallery";
export default async function SearchPage({ params }: { params: { query: string } }) {
const data = await fetchGalleryData({ query: params.query })
return <Layout>
<Search data={data} query={params.query} />
</Layout>
}

View File

@ -0,0 +1,10 @@
import Layout from "@/components/Layout";
import Search from "@/components/Pages/Search";
export default async function SearchNoQueryPage() {
return <Layout>
<Search data={[]} query={''} />
</Layout>
}

View File

@ -0,0 +1,26 @@
import { redirect } from 'next/navigation';
import Layout from "@/components/Layout";
import Video from "@/components/Pages/Video";
import { fetchVideoData } from "@/utils/scrape/video";
import { useLocale } from 'next-intl';
export default async function VideoPage({ params }: { params: { id: string } }) {
const locale = useLocale()
const decodedId = decodeURIComponent(params.id)
const [data, related] = await fetchVideoData(decodedId)
if (!data.lowResUrl) {
redirect(`/${locale}/404`)
}
return <Layout>
<Video id={params.id} data={data} related={related}/>
</Layout>
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,107 +0,0 @@
:root {
--max-width: 1100px;
--border-radius: 12px;
--font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
"Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro",
"Fira Mono", "Droid Sans Mono", "Courier New", monospace;
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
--primary-glow: conic-gradient(
from 180deg at 50% 50%,
#16abff33 0deg,
#0885ff33 55deg,
#54d6ff33 120deg,
#0071ff33 160deg,
transparent 360deg
);
--secondary-glow: radial-gradient(
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0)
);
--tile-start-rgb: 239, 245, 249;
--tile-end-rgb: 228, 232, 233;
--tile-border: conic-gradient(
#00000080,
#00000040,
#00000030,
#00000020,
#00000010,
#00000010,
#00000080
);
--callout-rgb: 238, 240, 241;
--callout-border-rgb: 172, 175, 176;
--card-rgb: 180, 185, 188;
--card-border-rgb: 131, 134, 135;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
--secondary-glow: linear-gradient(
to bottom right,
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0.3)
);
--tile-start-rgb: 2, 13, 46;
--tile-end-rgb: 2, 5, 19;
--tile-border: conic-gradient(
#ffffff80,
#ffffff40,
#ffffff30,
#ffffff20,
#ffffff10,
#ffffff10,
#ffffff80
);
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
}

View File

@ -1,22 +0,0 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

View File

@ -1,230 +0,0 @@
.main {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
padding: 6rem;
min-height: 100vh;
}
.description {
display: inherit;
justify-content: inherit;
align-items: inherit;
font-size: 0.85rem;
max-width: var(--max-width);
width: 100%;
z-index: 2;
font-family: var(--font-mono);
}
.description a {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
}
.description p {
position: relative;
margin: 0;
padding: 1rem;
background-color: rgba(var(--callout-rgb), 0.5);
border: 1px solid rgba(var(--callout-border-rgb), 0.3);
border-radius: var(--border-radius);
}
.code {
font-weight: 700;
font-family: var(--font-mono);
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(25%, auto));
max-width: 100%;
width: var(--max-width);
}
.card {
padding: 1rem 1.2rem;
border-radius: var(--border-radius);
background: rgba(var(--card-rgb), 0);
border: 1px solid rgba(var(--card-border-rgb), 0);
transition: background 200ms, border 200ms;
}
.card span {
display: inline-block;
transition: transform 200ms;
}
.card h2 {
font-weight: 600;
margin-bottom: 0.7rem;
}
.card p {
margin: 0;
opacity: 0.6;
font-size: 0.9rem;
line-height: 1.5;
max-width: 30ch;
text-wrap: balance;
}
.center {
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 4rem 0;
}
.center::before {
background: var(--secondary-glow);
border-radius: 50%;
width: 480px;
height: 360px;
margin-left: -400px;
}
.center::after {
background: var(--primary-glow);
width: 240px;
height: 180px;
z-index: -1;
}
.center::before,
.center::after {
content: "";
left: 50%;
position: absolute;
filter: blur(45px);
transform: translateZ(0);
}
.logo {
position: relative;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
.card:hover {
background: rgba(var(--card-rgb), 0.1);
border: 1px solid rgba(var(--card-border-rgb), 0.15);
}
.card:hover span {
transform: translateX(4px);
}
}
@media (prefers-reduced-motion) {
.card:hover span {
transform: none;
}
}
/* Mobile */
@media (max-width: 700px) {
.content {
padding: 4rem;
}
.grid {
grid-template-columns: 1fr;
margin-bottom: 120px;
max-width: 320px;
text-align: center;
}
.card {
padding: 1rem 2.5rem;
}
.card h2 {
margin-bottom: 0.5rem;
}
.center {
padding: 8rem 0 6rem;
}
.center::before {
transform: none;
height: 300px;
}
.description {
font-size: 0.8rem;
}
.description a {
padding: 1rem;
}
.description p,
.description div {
display: flex;
justify-content: center;
position: fixed;
width: 100%;
}
.description p {
align-items: center;
inset: 0 0 auto;
padding: 2rem 1rem 1.4rem;
border-radius: 0;
border: none;
border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
background: linear-gradient(
to bottom,
rgba(var(--background-start-rgb), 1),
rgba(var(--callout-rgb), 0.5)
);
background-clip: padding-box;
backdrop-filter: blur(24px);
}
.description div {
align-items: flex-end;
pointer-events: none;
inset: auto 0 0;
padding: 2rem;
height: 200px;
background: linear-gradient(
to bottom,
transparent 0%,
rgb(var(--background-end-rgb)) 40%
);
z-index: 1;
}
}
/* Tablet and Smaller Desktop */
@media (min-width: 701px) and (max-width: 1120px) {
.grid {
grid-template-columns: repeat(2, 50%);
}
}
@media (prefers-color-scheme: dark) {
.vercelLogo {
filter: invert(1);
}
.logo {
filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
}
}
@keyframes rotate {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}

View File

@ -1,95 +0,0 @@
import Image from "next/image";
import styles from "./page.module.css";
export default function Home() {
return (
<main className={styles.main}>
<div className={styles.description}>
<p>
Get started by editing&nbsp;
<code className={styles.code}>src/app/page.tsx</code>
</p>
<div>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
By{" "}
<Image
src="/vercel.svg"
alt="Vercel Logo"
className={styles.vercelLogo}
width={100}
height={24}
priority
/>
</a>
</div>
</div>
<div className={styles.center}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js Logo"
width={180}
height={37}
priority
/>
</div>
<div className={styles.grid}>
<a
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Docs <span>-&gt;</span>
</h2>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Learn <span>-&gt;</span>
</h2>
<p>Learn about Next.js in an interactive course with&nbsp;quizzes!</p>
</a>
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Templates <span>-&gt;</span>
</h2>
<p>Explore starter templates for Next.js.</p>
</a>
<a
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
className={styles.card}
target="_blank"
rel="noopener noreferrer"
>
<h2>
Deploy <span>-&gt;</span>
</h2>
<p>
Instantly deploy your Next.js site to a shareable URL with Vercel.
</p>
</a>
</div>
</main>
);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import {useTranslations} from 'next-intl';
const Description: React.FC = () => {
const t = useTranslations('Header');
return (
<div>{t('description')}</div>
);
};
export default Description;

View File

@ -0,0 +1,10 @@
@import 'colors';
@import 'spacing';
.messageBox {
border: 1px solid $colors_yellow;
text-align: center;
padding: $spacing_8;
border-radius: $spacing_4;
margin-bottom: $spacing_32;
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import style from './Disclaimer.module.scss'
import { useTranslations } from 'next-intl';
const Disclaimer: React.FC = () => {
const MAX_DISCLAIMER_NO = 6
const t = useTranslations('Header');
const getRandomArbitrary = (max: number) => {
return Math.floor( Math.random() * max);
}
return (
<div className={style.messageBox}>{t(`disclaimer_${getRandomArbitrary(MAX_DISCLAIMER_NO)}`)}</div>
);
};
export default Disclaimer;

View File

@ -0,0 +1,16 @@
import React from 'react';
import {useTranslations} from 'next-intl';
import Link from 'next/link';
const Title: React.FC = () => {
const t = useTranslations('Header');
return (
<Link href={'/'}><h1>{t('title')}</h1></Link>
);
};
export default Title;

View File

@ -0,0 +1,18 @@
import React from 'react';
import Title from './Title';
import Description from './Description';
import Disclaimer from './Disclaimer';
const Header: React.FC = () => {
return (
<>
<Title />
<Description />
<Disclaimer />
</>
);
};
export default Header;

View File

@ -0,0 +1,5 @@
@import 'spacing';
.container {
margin-bottom: $spacing_32;
}

View File

@ -0,0 +1,65 @@
'use client';
import React from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
export const VideoJS = (props: { options: any; onReady: any; }) => {
const videoRef = React.useRef(null);
const playerRef = React.useRef(null);
const {options, onReady} = props;
React.useEffect(() => {
// Make sure Video.js player is only initialized once
if (!playerRef.current) {
// The Video.js player needs to be _inside_ the component el for React 18 Strict Mode.
const videoElement = document.createElement("video-js");
videoElement.classList.add('vjs-big-play-centered');
//@ts-ignore
videoRef.current.appendChild(videoElement);
//@ts-ignore
const player = playerRef.current = videojs(videoElement, options, () => {
videojs.log('player is ready');
onReady && onReady(player);
});
// You could update an existing player in the `else` block here
// on prop change, for example:
} else {
const player = playerRef.current;
//@ts-ignore
player.autoplay(options.autoplay);
//@ts-ignore
player.src(options.sources);
}
}, [options, videoRef]);
// Dispose the Video.js player when the functional component unmounts
React.useEffect(() => {
const player = playerRef.current;
return () => {
//@ts-ignore
if (player && !player.isDisposed()) {
//@ts-ignore
player.dispose();
playerRef.current = null;
}
};
}, [playerRef]);
return (
<div data-vjs-player>
<div ref={videoRef} />
</div>
);
}
export default VideoJS;

View File

@ -0,0 +1,63 @@
'use client'
import React from 'react';
import style from './Player.module.scss'
import VideoJS from './VideoJS';
import { VideoData } from '@/meta/data';
import 'videojs-hls-quality-selector';
interface Props {
data: VideoData
}
const Player: React.FC<Props> = (props) => {
const { data } = props;
const videoSrc = data.hlsUrl ?? data.lowResUrl
const videoType = data.hlsUrl ? 'application/x-mpegURL' : 'video/mp4'
const playerRef = React.useRef(null);
const videoJsOptions = {
autoplay: true,
controls: true,
responsive: true,
fluid: true,
sources: [{
src: videoSrc,
type: videoType
}]
};
//@ts-ignore
const handlePlayerReady = (player) => {
playerRef.current = player;
player.hlsQualitySelector({
vjsIconClass: "vjs-icon-hd"
})
// You can handle player events here, for example:
player.on('waiting', () => {
console.log('player is waiting');
});
player.on('dispose', () => {
console.log('player will dispose');
});
};
return (
<div className={style.container}>
<VideoJS options={videoJsOptions} onReady={handlePlayerReady} />
</div>
);
};
export default Player;

View File

@ -0,0 +1,31 @@
@import 'spacing';
@import 'breakpoints';
.galleryContainer {
display: grid;
gap: $spacing_16;
grid-template-columns: repeat(3, 1fr);
@media only screen and (min-width: $portrait) {
grid-template-columns: repeat(3, 1fr);
}
@media only screen and (min-width: $tablet) {
grid-template-columns: repeat(2, 1fr);
}
@media only screen and (min-width: $laptop) {
grid-template-columns: repeat(3, 1fr);
}
@media only screen and (min-width: $desktop) {
grid-template-columns: repeat(4, 1fr);
}
}
.galleryContainer.show {
@media only screen and (max-width: $portrait) {
grid-template-columns: repeat(1, 1fr);
}
}

View File

@ -0,0 +1,50 @@
@import 'spacing';
@import 'colors';
@import 'breakpoints';
.text {
font-size: 12px;
color: $colors_yellow;
text-decoration: none;
display: none;
@media only screen and (min-width: $tablet) {
display: block;
}
}
.image {
border-radius: $spacing_4;
width: 100%;
}
.thumbnailContainer {
opacity: 0.1;
transition: 1s;
a {
text-decoration: none !important;
}
}
.thumbnailContainer:hover {
opacity: 1;
}
.thumbnailContainer.show {
opacity: 1;
.text {
display: block;
}
.image {
width: 100%;
@media only screen and (min-width: $tablet) {
width: auto;
}
}
}

View File

@ -0,0 +1,35 @@
'use client'
import React from 'react';
import classNames from 'classnames';
import Link from 'next/link'
import style from './Thumbnail.module.scss'
interface Props {
locale: string
videoUrl: string
imgUrl: string
text: string
show: boolean
}
const Thumbnail: React.FC<Props> = (props) => {
const { locale, videoUrl, imgUrl, text, show } = props
const encodedUri = encodeURIComponent(videoUrl)
return (
<div className={classNames(style.thumbnailContainer, { [style.show]: show } )}>
<Link href={`/${locale}/video/${encodedUri}`}>
<img className={style.image} src={imgUrl} />
<div className={style.text}>{text}</div>
</Link>
</div>
);
};
export default Thumbnail;

View File

@ -0,0 +1,38 @@
'use client'
import React from 'react';
import classNames from 'classnames';
import style from './Gallery.module.scss'
import Thumbnail from './Thumbnail';
import { GalleryData } from '@/meta/data';
interface Props {
data: GalleryData[]
show: boolean
locale: string
}
const Gallery: React.FC<Props> = (props) => {
const { show, locale, data } = props
return (
<>
<div className={classNames(style.galleryContainer, { [style.show]: show })}>
{data && data.map((elem, key) => {
return <Thumbnail
show={show}
key={key}
imgUrl={elem.imgUrl}
videoUrl={elem.videoUrl}
text={elem.text}
locale={locale} />
})}
</div>
</>
);
};
export default Gallery;

View File

@ -0,0 +1,22 @@
'use client'
import React, { useState } from 'react';
import style from './NoData.module.scss';
interface Props {
msg: string
}
const NoData: React.FC<Props> = (props) => {
const { msg } = props;
return (
<div className={style.container}>
<span>{msg}</span>
</div>
);
};
export default NoData;

View File

@ -0,0 +1,7 @@
@import 'breakpoints';
.toggleContainer {
@media only screen and (min-width: $tablet) {
display: none;
}
}

View File

@ -0,0 +1,24 @@
'use client'
import React from 'react';
import style from './Toggle.module.scss'
interface Props {
label: string
handleClick: () => void
}
const Toggle: React.FC<Props> = (props) => {
const { label, handleClick } = props
return (
<div className={style.toggleContainer}>
<input onChange={handleClick} type="checkbox" role="switch" name='toggle' />
<div className={style.label}>{label}</div>
</div>
);
};
export default Toggle;

View File

@ -0,0 +1,38 @@
'use client'
import React, { useState } from 'react';
import style from './Wrapper.module.scss';
import Gallery from './Gallery';
import Toggle from './Toggle';
import { GalleryData } from '@/meta/data';
import NoData from './NoData';
interface Props {
data: GalleryData[]
locale: string
labels: {
toggle: string
noData: string
}
}
const Wrapper: React.FC<Props> = (props) => {
const { labels, locale, data } = props
const [show, setShow] = useState<boolean>(false)
return (
<div className={style.resultsContainer}>
{(data && data.length > 0) && <>
<Toggle label={labels.toggle} handleClick={() => setShow(!show)} />
<Gallery data={data} locale={locale} show={show} />
</>}
{(!data || data.length == 0) && <NoData msg={labels.noData}/>}
</div>
);
};
export default Wrapper;

View File

@ -0,0 +1,22 @@
import React, { } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import Wrapper from './Wrapper';
import { GalleryData } from '@/meta/data';
interface Props {
data: GalleryData[]
}
const Results: React.FC<Props> = (props) => {
const { data } = props
const t = useTranslations('Results');
const locale = useLocale()
return (<Wrapper data={data} locale={locale} labels={{ toggle: t('toggle'), noData: t('noData') }} />);
};
export default Results;

View File

@ -0,0 +1,30 @@
@import 'spacing';
@import 'colors';
.searchForm {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.query{
flex: 6;
margin-right: $spacing_16;
}
.submitBtn{
flex: 1;
background-color: $colors_yellow;
border-color: $colors_yellow;
color: $colors_yellow_inverse;
}
.submitBtn:hover {
background-color: $colors_yellow_hover;
border-color: $colors_yellow_hover;
}
.submitBtn:focus {
background-color: $colors_yellow_focus;
border-color: $colors_yellow_focus;
}

View File

@ -0,0 +1,45 @@
'use client'
import React from 'react';
import style from './SearchBarForm.module.scss'
import { useRouter } from 'next/navigation'
interface Props {
query?: string
locale: string
labels: {
query: { placeholder: string }
submit: { value: string }
}
}
const MAX_SEARCH_LENGTH = 50;
const SearchBarForm: React.FC<Props> = (props) => {
const { query, labels, locale } = props;
const router = useRouter()
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
//@ts-ignore
const query = event.target.query.value
if (query.length > 0) {
router.push(`/${locale}/search/${query}`)
}
}
return (
<form onSubmit={handleSubmit} className={style.searchForm} method="get" action="/">
<input className={style.query} type="text" size={MAX_SEARCH_LENGTH} name="query" placeholder={labels.query.placeholder} defaultValue={query ?? undefined} />
<input className={style.submitBtn} type="submit" value={labels.submit.value} />
</form>
);
};
export default SearchBarForm;

View File

@ -0,0 +1,25 @@
import React from 'react';
import { useTranslations, useLocale } from 'next-intl';
import SearchBarForm from './SearchBarForm';
interface Props {
query?: string
}
const SearchBar: React.FC<Props> = (props) => {
const { query } = props;
const t = useTranslations('Search');
const locale = useLocale()
return (
<SearchBarForm query={query} locale={locale} labels={{
query: { placeholder: t('placeholder')},
submit: { value: t('submit')}
}} />
);
};
export default SearchBar;

View File

@ -0,0 +1,14 @@
import React from 'react';
const Layout: React.FC<React.PropsWithChildren> = (props) => {
const { children } = props;
return (
<main className="container">
{children}
</main>
);
};
export default Layout;

View File

@ -0,0 +1,26 @@
import React from 'react';
import Header from '@/components/Layout/Header';
import SearchBar from '@/components/Layout/SearchBar';
import Results from '@/components/Layout/Results';
import { GalleryData } from '@/meta/data';
interface Props {
data: GalleryData[]
}
const Home: React.FC<Props> = (props) => {
const { data } = props;
return (
<>
<Header />
<SearchBar />
<Results data={data} />
</>
);
};
export default Home;

View File

@ -0,0 +1,26 @@
import React from 'react';
import Header from '@/components/Layout/Header';
import SearchBar from '@/components/Layout/SearchBar';
import Results from '@/components/Layout/Results';
import { GalleryData } from '@/meta/data';
interface Props {
query: string
data: GalleryData[]
}
const Search: React.FC<Props> = (props) => {
const { query, data } = props;
return (
<>
<Header />
<SearchBar query={query} />
<Results data={data} />
</>
);
};
export default Search;

View File

@ -0,0 +1,31 @@
import React from 'react';
import Header from '@/components/Layout/Header';
import Player from '@/components/Layout/Player';
import SearchBar from '@/components/Layout/SearchBar';
import Results from '@/components/Layout/Results';
import { GalleryData, VideoData } from '@/meta/data';
interface Props {
id: string
data: VideoData
related: GalleryData[]
}
const Video: React.FC<Props> = (props) => {
const { data, related } = props;
return (
<>
<Header />
<Player data={data} />
<SearchBar />
{related && <Results data={related} />}
</>
);
};
export default Video;

1
src/constants/urls.ts Normal file
View File

@ -0,0 +1 @@
export const XVIDEOS_BASE_URL: string = "https://www.xvideos.com"

14
src/i18n.ts Normal file
View File

@ -0,0 +1,14 @@
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
// Can be imported from a shared config
const locales = ['en', 'it'];
export default getRequestConfig(async ({locale}) => {
// Validate that the incoming `locale` parameter is valid
if (!locales.includes(locale as any)) notFound();
return {
messages: (await import(`../locale/${locale}.json`)).default
};
});

11
src/meta/data.ts Normal file
View File

@ -0,0 +1,11 @@
export interface GalleryData {
videoUrl: string
imgUrl: string
text: string
}
export interface VideoData {
lowResUrl: string,
hiResUrl?: string,
hlsUrl?: string
}

14
src/middleware.ts Normal file
View File

@ -0,0 +1,14 @@
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
// A list of all locales that are supported
locales: ['en', 'it'],
// Used when no locale matches
defaultLocale: 'it'
});
export const config = {
// Match only internationalized pathnames
matcher: ['/', '/(en|it)/:path*']
};

View File

@ -0,0 +1,4 @@
$portrait: 40em;
$tablet: 48em;
$laptop: 64em;
$desktop: 80em;

5
src/styles/_colors.scss Normal file
View File

@ -0,0 +1,5 @@
// primaries
$colors_yellow: #fdd835;
$colors_yellow_hover: #fbc02d;
$colors_yellow_focus: rgba(253, 216, 53, 0.125);
$colors_yellow_inverse: rgba(0, 0, 0, 0.75);

4
src/styles/_spacing.scss Normal file
View File

@ -0,0 +1,4 @@
$spacing_4: 4px;
$spacing_8: 8px;
$spacing_16: 16px;
$spacing_32: 32px;

1
src/styles/globals.scss Normal file
View File

@ -0,0 +1 @@
@use "@picocss/pico";

View File

@ -0,0 +1,53 @@
import { XVIDEOS_BASE_URL } from '@/constants/urls';
import { GalleryData, VideoData } from '@/meta/data';
import axios, { AxiosError } from 'axios';
import * as cheerio from "cheerio";
interface FetchParams {
baseUrl?: string
query?: string
}
export const fetchGalleryData = async (params?: FetchParams): Promise<GalleryData[]> => {
let data: GalleryData[] = [];
const reqHeaders = {
headers: {
"User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5397.215 Safari/537.36'
},
};
const queryUrl = `${(params && params.baseUrl) ?? XVIDEOS_BASE_URL}${params && params.query ? '/?k=' + params.query : ''}`
await axios.get(queryUrl, reqHeaders)
.then(response => {
const html = response.data;
const $ = cheerio.load(html);
const thumbs = $(".thumb-block");
thumbs.map((key, thumb) => {
const videoUrl = $(thumb).find(".thumb a").attr("href")
const imgUrl = $(thumb).find(".thumb img").attr("data-src")
const text = $(thumb).find(".thumb-under a").attr("title")
videoUrl && imgUrl && text && data.push({
videoUrl,
imgUrl,
text
})
})
}).catch((error: AxiosError) => {
// handle errors
});
return data
}

76
src/utils/scrape/video.ts Normal file
View File

@ -0,0 +1,76 @@
import { XVIDEOS_BASE_URL } from '@/constants/urls';
import { GalleryData, VideoData } from '@/meta/data';
import axios, { AxiosError } from 'axios';
import * as cheerio from "cheerio";
import { findRelatedVideos, findVideoUrlInsideTagStringByFunctionNameAndExtension } from '../string';
interface FetchParams {
baseUrl?: string
query?: string
}
export const fetchVideoData = async (videoId: string, params?: FetchParams): Promise<[VideoData, GalleryData[]]> => {
let data: VideoData = {
lowResUrl: ''
}
let related: GalleryData[] = [];
const reqHeaders = {
headers: {
"User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.5397.215 Safari/537.36'
},
};
const queryUrl = `${(params && params.baseUrl) ?? XVIDEOS_BASE_URL}${videoId}`
await axios.get(queryUrl, reqHeaders)
.then(response => {
const html = response.data;
const $ = cheerio.load(html);
const scriptTags = $("script");
// populate video data object
scriptTags.map((idx, elem) => {
const lowResUrl = findVideoUrlInsideTagStringByFunctionNameAndExtension($(elem).toString(), 'setVideoUrlLow', '.mp4')
const hiResUrl = findVideoUrlInsideTagStringByFunctionNameAndExtension($(elem).toString(), 'setVideoUrlHigh', '.mp4')
const hlsUrl = findVideoUrlInsideTagStringByFunctionNameAndExtension($(elem).toString(), 'setVideoHLS', '.m3u8')
if (lowResUrl) {
data.lowResUrl = lowResUrl;
}
if (hiResUrl) {
data.hiResUrl = hiResUrl
}
if (hlsUrl) {
data.hlsUrl = hlsUrl
}
})
// populate related gallery
scriptTags.map((idx, elem) => {
const relatedVideos = findRelatedVideos($(elem).toString())
if (relatedVideos) {
related = relatedVideos
}
})
}).catch((error: AxiosError) => {
// handle errors
});
return [data, related];
}

42
src/utils/string.ts Normal file
View File

@ -0,0 +1,42 @@
import { GalleryData } from "@/meta/data";
export const findVideoUrlInsideTagStringByFunctionNameAndExtension = (
tagBlock: string, functionName: string, extension: string): string|null => {
const start = tagBlock.indexOf(`html5player.${functionName}('`) + `html5player.${functionName}('`.length;
const end = tagBlock.toString().indexOf("'", start);
const substr = tagBlock.substring(start, end);
if (substr.includes(extension)) {
return substr
}
return null
}
export const findRelatedVideos = (tagBlock: string): GalleryData[]|null => {
if (!(tagBlock.includes('video_related=['))) {
return null
}
// Trova l'inizio e la fine dell'array di oggetti nell'input
const start = tagBlock.indexOf('[{');
const end = tagBlock.lastIndexOf('}]') + 2;
// Estrai la sottostringa contenente l'array di oggetti
const jsonString = tagBlock.substring(start, end);
// Parsea la stringa JSON in un array di oggetti
const videoRelatedArray = JSON.parse(jsonString);
// Mappa ogni oggetto nell'array per rinominare le chiavi
//@ts-ignore
const parsedArray = videoRelatedArray.map(obj => ({
//@ts-ignore
videoUrl: obj.u,
imgUrl: obj.i,
text: obj.tf
}));
return parsedArray;
}

View File

@ -23,4 +23,4 @@
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
}

199
video.html Normal file

File diff suppressed because one or more lines are too long