progress
|
@ -1,7 +1,11 @@
|
|||
# Created by https://www.toptal.com/developers/gitignore/api/nextjs
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=nextjs
|
||||
|
||||
|
||||
.vscode/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
dist/
|
||||
|
||||
### NextJS ###
|
||||
|
|
|
@ -14,40 +14,27 @@ export default async function Page() {
|
|||
|
||||
<div className="block">
|
||||
|
||||
<h1>Mission</h1>
|
||||
|
||||
<div className="block">
|
||||
<section className="hero is-primary">
|
||||
<div className="hero-body">
|
||||
<p className="subtitle">Unofficial <Link target="_blank" href="https://twitter.com/projektelody" className="subtitle is-5"><span className="mr-2"><FontAwesomeIcon icon={faTwitter} className="fab fa-twitter" /></span><span className="mr-2">ProjektMelody</span><span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></span>
|
||||
</Link> Chaturbate VOD Archive. For Adults Only. (NSFW)</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p>It's a lofty goal, but Futureporn aims to become <b>the Galaxy's best VTuber hentai site.</b></p>
|
||||
|
||||
<h1>The Story of Futureporn</h1>
|
||||
<h2>How do we get there?</h2>
|
||||
|
||||
<h3>1. Solve the viewer's common problems</h3>
|
||||
|
||||
<p>2020 was a busy time for me. I started a small business, attended lots of support group meetings, and rode my bicycle more than ever before. I often found myself away from home during times when Melody was streaming on Chaturbate.</p>
|
||||
<p>Viewers want to watch livestream VODs on their own time. Futureporn collects vods from public streams, and caches them for later viewing.</p>
|
||||
|
||||
<p>You probably know that unlike other video streaming platforms, Chaturbate doesn’t store any VODs. When I missed a stream, I felt sad. I felt like I had missed out and there’s no way I’d ever find out what happened.</p>
|
||||
<p>Viewers want to find content that interests them. Futureporn enables vod tagging for easy browsing.</p>
|
||||
|
||||
<p>I’m pretty handy with computer software. Creating programs and websites has been my biggest passion for my entire life. In order to never miss a ProjektMelody livestream again, I resolved to create some software that would automatically record Melody’s Chaturbate streams.</p>
|
||||
<h3>2. Solve the streamer's common problems</h3>
|
||||
|
||||
<p>I put the project on hold for a few months, because I didn’t think I could make a website that could handle the traffic that the Science Team would generate.</p>
|
||||
<p>Platforms like PH are not rising to the needs of VTubers. Instead of offering support and resources, they penalize and ban top talent.</p>
|
||||
|
||||
<p>I couldn’t shake the idea, though. I wanted Futureporn to exist no matter what!</p>
|
||||
<p>Futureporn is different, embracing the medium and leveraging emerging technologies to amplify VTuber success.</p>
|
||||
|
||||
<p>I’ve been working on this project off and on for about a year and a half. It’s gone through several iterations, and each iteration has taught me something new. Right now, the website is usable for finding and downloading ProjektMelody Chaturbate VODs. Every VOD has a link to Melody’s tweet which originally announced the stream, and a title/description derived from said tweet. I have archived { complete } out of her { total } known Chaturbate streams.</p>
|
||||
<h3>3. Scale beyond Earth</h3>
|
||||
|
||||
<p>The project has evolved over time. Originally, I wanted to have a place to go when I missed one of Melody’s livestreams. Now, the project is becoming a sort of a time capsule. We’ve all seen how Melody has been de-platformed a half dozen times, and I’ve taken this to heart. Platforms are a problem for data preservation! This is one of the reasons for why I chose to use the Inter-Planetary File System (<Link target="_blank" href="https://ipfs.io/">IPFS<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.)</p>
|
||||
|
||||
<p>IPFS can end 404s through “pinning,” a way of mirroring a file across several different computers. It’s a way for computers to work together to serve content instead of working independently, thus gaining redundancy and performance benefits. I see a future where pinning files on IPFS becomes as easy as pinning a photo on Pinterest. Fans of ProjektMelody can pin the VODs on Futureporn, increasing that VOD’s replication and servability to future viewers.</p>
|
||||
|
||||
<p>But wait, there’s more! I have been thinking about a bunch of other stuff that could be done with past VODs. I think the most exciting thing would be to use computer vision to parse Melody’s vibrator activity from the video, and export to a data file. This data file could be used to send good vibes to a viewer’s vibrator in-sync with VOD playback. Feel what Melody feels! Very exciting, very sexy! This is a long-term goal for Futureporn.</p>
|
||||
|
||||
<p>I have several goals for Futureporn, as listed on the <Link href="/goals">Goals page</Link>. A bunch of them have to do with increasing video playback performance, user interface design, but there’s a few that are pretty eccentric… Serving ProjektMelody VODs to Mars, for example!</p>
|
||||
|
||||
<p>I hope this site is useful to all the Science Team!</p>
|
||||
<p>Piggybacking on <Link href="/faq#ipfs">IPFS</Link>'s potential to end 404s, VODs preserved here can withstand the test of time, and eventually exist off-world.</p>
|
||||
|
||||
<article className="mt-5 message is-success">
|
||||
<div className="message-body">
|
||||
|
|
|
@ -15,9 +15,9 @@ export async function GET() {
|
|||
thiccHash: '',
|
||||
announceTitle: v.announceTitle,
|
||||
announceUrl: v.announceUrl,
|
||||
date: v.date,
|
||||
date: v.date2,
|
||||
note: v.note || '',
|
||||
url: getUrl(v)
|
||||
url: getUrl(v, v.vtuber.slug, v.date2)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import { getGoals } from "@/lib/patreon"
|
||||
import { getVodsForVtuber } from "@/lib/vods"
|
||||
import { IVods } from "@/lib/vods"
|
||||
import Link from "next/link"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"
|
||||
|
||||
export default async function Page() {
|
||||
// const goals = await getGoals()
|
||||
// const { fundedGoals, unfundedGoals } = goals;
|
||||
// const complete = fundedGoals;
|
||||
const vods: IVods = await getVodsForVtuber(1)
|
||||
const complete = vods.pagination.total
|
||||
const total = vods.pagination.total
|
||||
|
||||
return (
|
||||
<div className="content">
|
||||
<div className="box">
|
||||
|
||||
<div className="block">
|
||||
|
||||
|
||||
|
||||
|
||||
<h1>The Story of Futureporn</h1>
|
||||
|
||||
<p>2020 was a busy time for me. I started a small business, attended lots of support group meetings, and rode my bicycle more than ever before. I often found myself away from home during times when Melody was streaming on Chaturbate.</p>
|
||||
|
||||
<p>You probably know that unlike other video streaming platforms, Chaturbate doesn’t store any VODs. When I missed a stream, I felt sad. I felt like I had missed out and there’s no way I’d ever find out what happened.</p>
|
||||
|
||||
<p>I’m pretty handy with computer software. Creating programs and websites has been my biggest passion for my entire life. In order to never miss a ProjektMelody livestream again, I resolved to create some software that would automatically record Melody’s Chaturbate streams.</p>
|
||||
|
||||
<p>I put the project on hold for a few months, because I didn’t think I could make a website that could handle the traffic that the Science Team would generate.</p>
|
||||
|
||||
<p>I couldn’t shake the idea, though. I wanted Futureporn to exist no matter what!</p>
|
||||
|
||||
<p>I’ve been working on this project off and on for about a year and a half. It’s gone through several iterations, and each iteration has taught me something new. Right now, the website is usable for finding and downloading ProjektMelody Chaturbate VODs. Every VOD has a link to Melody’s tweet which originally announced the stream, and a title/description derived from said tweet. I have archived { complete } out of her { total } known Chaturbate streams.</p>
|
||||
|
||||
<p>The project has evolved over time. Originally, I wanted to have a place to go when I missed one of Melody’s livestreams. Now, the project is becoming a sort of a time capsule. We’ve all seen how Melody has been de-platformed a half dozen times, and I’ve taken this to heart. Platforms are a problem for data preservation! This is one of the reasons for why I chose to use the Inter-Planetary File System (<Link target="_blank" href="https://ipfs.io/">IPFS<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.)</p>
|
||||
|
||||
<p>IPFS can end 404s through “pinning,” a way of mirroring a file across several different computers. It’s a way for computers to work together to serve content instead of working independently, thus gaining redundancy and performance benefits. I see a future where pinning files on IPFS becomes as easy as pinning a photo on Pinterest. Fans of ProjektMelody can pin the VODs on Futureporn, increasing that VOD’s replication and servability to future viewers.</p>
|
||||
|
||||
<p>But wait, there’s more! I have been thinking about a bunch of other stuff that could be done with past VODs. I think the most exciting thing would be to use computer vision to parse Melody’s vibrator activity from the video, and export to a data file. This data file could be used to send good vibes to a viewer’s vibrator in-sync with VOD playback. Feel what Melody feels! Very exciting, very sexy! This is a long-term goal for Futureporn.</p>
|
||||
|
||||
<p>I have several goals for Futureporn, as listed on the <Link href="/goals">Goals page</Link>. A bunch of them have to do with increasing video playback performance, user interface design, but there’s a few that are pretty eccentric… Serving ProjektMelody VODs to Mars, for example!</p>
|
||||
|
||||
<p>I hope this site is useful to all the Science Team!</p>
|
||||
|
||||
<article className="mt-5 message is-success">
|
||||
<div className="message-body">
|
||||
<p>Futureporn needs financial support to continue improving. If you enjoy this website, please consider <Link target="_blank" href="https://patreon.com/CJ_Clippy">becoming a patron<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export default function Page() {
|
||||
return (
|
||||
<p>blog</p>
|
||||
)
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { ReactNode } from 'react'
|
||||
import { strapiUrl } from '../lib/constants';
|
||||
import { strapiUrl, patreonQuantumSupporterId, patreonSupporterBenefitId } from '../lib/constants';
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { faPatreon } from "@fortawesome/free-brands-svg-icons";
|
||||
import { useLocalStorageValue } from "@react-hookz/web";
|
||||
|
@ -13,6 +13,16 @@ type Props = {
|
|||
children?: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
export interface IAuthData {
|
||||
accessToken: string | null;
|
||||
user: IUser | null;
|
||||
}
|
||||
|
||||
interface IUseAuth {
|
||||
authData: IAuthData;
|
||||
}
|
||||
|
||||
interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
|
@ -34,10 +44,6 @@ export interface IJWT {
|
|||
user: IUser | null;
|
||||
}
|
||||
|
||||
export interface IAuthData {
|
||||
accessToken: string | null;
|
||||
user: IUser | null;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<IAuthData>({
|
||||
accessToken: null,
|
||||
|
@ -46,7 +52,7 @@ export const AuthContext = createContext<IAuthData>({
|
|||
|
||||
export function deserializeAuthData (authData: string): IAuthData {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: Props): JSX.Element {
|
||||
|
||||
|
@ -127,7 +133,7 @@ export function LogoutButton() {
|
|||
}
|
||||
|
||||
|
||||
export function useAuth(): IAuthData {
|
||||
export function useAuth(): IUseAuth {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,19 +1,32 @@
|
|||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton"
|
||||
import { getContributors } from "../lib/contributors"
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { getContributors } from "../lib/contributors";
|
||||
import Link from 'next/link';
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export default async function Contributors() {
|
||||
const contributors = await getContributors()
|
||||
if (!contributors || contributors.length < 1) return (
|
||||
<SkeletonTheme baseColor="#000" highlightColor="#000" width="25%">
|
||||
<Skeleton count={1} enableAnimation={false}/>
|
||||
<Skeleton count={1} enableAnimation={false} />
|
||||
</SkeletonTheme>
|
||||
)
|
||||
const contributorList = contributors.map((contributor, index) => (
|
||||
<span key={index}>
|
||||
{contributor.name}
|
||||
{contributor.url ? (
|
||||
<Link href={contributor.url} target="_blank">
|
||||
<span className="mr-1">{contributor.name}</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faExternalLinkAlt}
|
||||
className="fab fa-external-link-alt"
|
||||
></FontAwesomeIcon>
|
||||
</Link>
|
||||
) : (
|
||||
contributor.name
|
||||
)}
|
||||
{index !== contributors.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))
|
||||
));
|
||||
return (
|
||||
<>{contributorList}</>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useEffect, useRef, useState, forwardRef, MutableRefObject } from "react";
|
||||
import { APITypes, PlyrProps, usePlyr } from "plyr-react";
|
||||
import "plyr-react/plyr.css";
|
||||
import { Options } from "plyr";
|
||||
import Hls from "hls.js";
|
||||
|
||||
|
||||
export function UnsupportedHlsMessage(): React.JSX.Element {
|
||||
return (
|
||||
<div className="unsupported-hls">
|
||||
HLS is not supported in your browser. Please try a different browser.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const useHls = (src: string, options: Options | null) => {
|
||||
const hls = useRef<Hls>(new Hls());
|
||||
const hasQuality = useRef<boolean>(false);
|
||||
const [plyrOptions, setPlyrOptions] = useState<Options | null>(options);
|
||||
|
||||
useEffect(() => {
|
||||
hasQuality.current = false;
|
||||
}, [options]);
|
||||
|
||||
useEffect(() => {
|
||||
hls.current.loadSource(src);
|
||||
hls.current.attachMedia(document.querySelector(".plyr-react")!);
|
||||
|
||||
|
||||
|
||||
hls.current.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (hasQuality.current) return; // early quit if already set
|
||||
|
||||
const levels = hls.current.levels;
|
||||
const quality: Options["quality"] = {
|
||||
default: levels[levels.length - 1].height,
|
||||
options: levels.map((level) => level.height),
|
||||
forced: true,
|
||||
onChange: (newQuality: number) => {
|
||||
levels.forEach((level, levelIndex) => {
|
||||
if (level.height === newQuality) {
|
||||
hls.current.currentLevel = levelIndex;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
setPlyrOptions({ ...plyrOptions, quality });
|
||||
hasQuality.current = true;
|
||||
});
|
||||
});
|
||||
|
||||
return { options: plyrOptions };
|
||||
};
|
||||
|
||||
export const CustomPlyrInstance = forwardRef<
|
||||
APITypes,
|
||||
PlyrProps & { hlsSource: string; mainColor: string; }
|
||||
>((props, ref) => {
|
||||
const { source, options = null, hlsSource, mainColor } = props;
|
||||
const plyrRef = usePlyr(ref, {
|
||||
...useHls(hlsSource, options),
|
||||
source,
|
||||
}) as MutableRefObject<HTMLVideoElement>;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current?.plyr?.isHTML5) return;
|
||||
if (!options?.previewThumbnails?.src) return;
|
||||
if (!ref.current.plyr.elements) return;
|
||||
ref.current.plyr.setPreviewThumbnails({
|
||||
src: options?.previewThumbnails?.src,
|
||||
});
|
||||
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<video
|
||||
ref={plyrRef}
|
||||
className="plyr-react plyr"
|
||||
style={{ "--plyr-color-main": mainColor } as React.CSSProperties}
|
||||
></video>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -24,7 +24,7 @@ export default function Footer() {
|
|||
<li><Link href="/tags">Tags</Link></li>
|
||||
<li><Link href="/feed">RSS Feed</Link></li>
|
||||
<li><Link href="/api">API</Link></li>
|
||||
<li><Link href="https://status.futureporn.net/" target="_blank">Status</Link></li>
|
||||
<li><Link href="https://status.futureporn.net/" target="_blank"><span className="mr-1">Status</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fab fa-external-link-alt"></FontAwesomeIcon></Link></li>
|
||||
<li><Link href="/upload">Upload</Link></li>
|
||||
<li><Link href="/profile">Profile</Link></li>
|
||||
</ul>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { getCampaign, getGoals, IGoalSample } from "../lib/patreon";
|
|||
|
||||
|
||||
|
||||
export default async function FundingGoal() {
|
||||
export default async function FundingGoal(): Promise<React.JSX.Element> {
|
||||
// const fundedGoal = patreon.goals.complete[patreon.goals.complete.length - 1];
|
||||
// const unfundedGoal = patreon.goals.incomplete[0];
|
||||
const { pledgeSum } = await getCampaign()
|
||||
|
|
|
@ -5,6 +5,9 @@ import Chaturbate from '@/assets/svg/chaturbate.svg'
|
|||
import Throne from '@/assets/svg/throne.svg'
|
||||
import Linktree from '@/assets/svg/linktree.svg'
|
||||
import Carrd from '@/assets/svg/carrd.svg'
|
||||
import Anime from '@/assets/svg/noun-anime-3890912.svg'
|
||||
import Avatar from '@/assets/svg/noun-avatar-3546974.svg'
|
||||
import Network from '@/assets/svg/noun-network-1603820.svg'
|
||||
|
||||
import styles from '@/assets/styles/icon.module.css'
|
||||
|
||||
|
@ -34,4 +37,16 @@ export function LinktreeIcon () {
|
|||
|
||||
export function CarrdIcon () {
|
||||
return <Carrd className={styles.icon} />
|
||||
}
|
||||
|
||||
export function AnimeIcon () {
|
||||
return <Anime className={`${styles.icon} ${styles.bigIcon}`} />
|
||||
}
|
||||
|
||||
export function AvatarIcon () {
|
||||
return <Avatar className={`${styles.icon} ${styles.bigIcon}`} />
|
||||
}
|
||||
|
||||
export function AdultContentIcon () {
|
||||
return <AdultContent className={`${styles.icon} ${styles.bigIcon}`} />
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import { createHelia, Helia } from 'helia'
|
||||
import { unixfs } from '@helia/unixfs'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
// const cid: CID = CID.parse('QmRBkKi1PnthqaBaiZnXML6fH6PNqCFdpcBxGYXoUQfp6z')
|
||||
|
||||
|
||||
async function catFile (heliaFs) {
|
||||
const textDecoder = new TextDecoder()
|
||||
// for await (const data of heliaFs.cat('QmRBkKi1PnthqaBaiZnXML6fH6PNqCFdpcBxGYXoUQfp6z')) {
|
||||
for await (const data of heliaFs.cat('bafybeickjad2c3w7kqgg52o27m5jbzd4uevnghpj4qqc2j2qijiomivcya')) {
|
||||
console.log(textDecoder.decode(data))
|
||||
}
|
||||
}
|
||||
|
||||
const IpfsComponent = () => {
|
||||
const [id, setId] = useState<CID>(null)
|
||||
const [helia, setHelia] = useState<Helia|null>(null)
|
||||
const [isOnline, setIsOnline] = useState<boolean>(false)
|
||||
const [content, setContent] = useState<Uint8Array>(new Uint8Array());
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (helia) return
|
||||
|
||||
const heliaNode = await createHelia()
|
||||
|
||||
const nodeId = heliaNode.libp2p.peerId.toString()
|
||||
const nodeIsOnline = heliaNode.libp2p.isStarted()
|
||||
|
||||
setHelia(heliaNode)
|
||||
setId(nodeId)
|
||||
setIsOnline(nodeIsOnline)
|
||||
}
|
||||
|
||||
init()
|
||||
}, [helia])
|
||||
|
||||
|
||||
|
||||
if (!helia || !id) {
|
||||
return <h4>Connecting to IPFS...</h4>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 data-test="id">ID: {id.toString()}</h4>
|
||||
<h4 data-test="status">Status: {isOnline ? 'Online' : 'Offline'}</h4>
|
||||
<button className="button is-primary" onClick={async () => {
|
||||
console.log(`getting content from network, probably (hopefully)`)
|
||||
|
||||
const heliaFs = unixfs(helia)
|
||||
|
||||
|
||||
await catFile(heliaFs)
|
||||
console.log('all done')
|
||||
|
||||
// const content = await helia.blockstore.get(cid, {
|
||||
// onProgress(evt) {
|
||||
// console.info(evt)
|
||||
// // console.info(`onProgress event with type=${evt.type}`)
|
||||
// // console.info(JSON.stringify(evt, null, 2))
|
||||
// },
|
||||
// })
|
||||
// console.log('finished getting cid')
|
||||
// console.log(content)
|
||||
// setContent(content)
|
||||
}}>GET</button>
|
||||
<p>content:{content} [length={content.length}]</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IpfsComponent
|
|
@ -0,0 +1,22 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import NProgress from 'nprogress';
|
||||
import "nprogress/nprogress.css";
|
||||
|
||||
|
||||
export function LoadingBar() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`LoadingBar useEffect was triggered`)
|
||||
NProgress.done();
|
||||
return () => {
|
||||
NProgress.start();
|
||||
};
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return <></>
|
||||
}
|
|
@ -42,7 +42,6 @@ export default function Navbar() {
|
|||
<Link className="navbar-item is-expanded" href="/goals">Goals</Link>
|
||||
<Link className="navbar-item is-expanded" href="/patrons">Patrons</Link>
|
||||
<Link className="navbar-item is-expanded" href="/tags">Tags</Link>
|
||||
<Link className="navbar-item is-expanded" href="/feed">RSS</Link>
|
||||
<Link className="navbar-item is-expanded" href="/api">API</Link>
|
||||
</div>
|
||||
<div className='navbar-end'>
|
||||
|
@ -57,8 +56,7 @@ export default function Navbar() {
|
|||
</div>
|
||||
|
||||
|
||||
<div className="navbar-item">
|
||||
{/* @todo x-bind:disabled="$store.auth.jwt===''" */}
|
||||
{/* <div className="navbar-item">
|
||||
<Link className="button " href="/upload">
|
||||
<span className="mr-1">Upload</span>
|
||||
<FontAwesomeIcon
|
||||
|
@ -66,7 +64,7 @@ export default function Navbar() {
|
|||
className="fas fa-upload"
|
||||
></FontAwesomeIcon>
|
||||
</Link>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="navbar-item fp-profile-button">
|
||||
{/* show the login button if user is anon */}
|
||||
|
|
|
@ -26,7 +26,7 @@ export default function Pager({ collection, slug, page, pageCount }: IPagerProps
|
|||
|
||||
return (
|
||||
<div className="box">
|
||||
<p>pager slug:{slug} page:{page} pageCount:{pageCount}</p>
|
||||
{/* <p>pager slug:{slug} page:{page} pageCount:{pageCount}</p> */}
|
||||
<nav className="pagination">
|
||||
{page > 1 && (
|
||||
<Link href={getPagePath(page - 1)} className="pagination-previous">
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
import React from 'react';
|
||||
import { usePlyr } from "plyr-react";
|
||||
|
||||
const PlyrCompo = React.forwardRef((props, ref) => {
|
||||
const { source, options = null, ...rest } = props
|
||||
const raptorRef = usePlyr(ref, {
|
||||
source,
|
||||
options,
|
||||
})
|
||||
return <video ref={raptorRef} className="plyr-react plyr" {...rest} />
|
||||
})
|
||||
|
||||
|
||||
export default PlyrCompo
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useRef, useEffect } from 'react';
|
||||
import { usePlyr } from 'plyr-react';
|
||||
import useHls from '@/components/custom-hls-player';
|
||||
import Plyr, { PlyrProps } from 'plyr-react/types';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
const Plyr2 = React.forwardRef((props: PlyrProps, ref: React.ForwardedRef<Plyr>) => {
|
||||
const { sources, options } = props;
|
||||
|
||||
// useEffect(() => {
|
||||
// const video = ref.current?.plyr?.media;
|
||||
// if (!video) return;
|
||||
|
||||
// const hls = new Hls();
|
||||
// if (props.source && props.source.src) {
|
||||
// hls.loadSource(props.source.src);
|
||||
// hls.attachMedia(video);
|
||||
|
||||
// hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
// ref.current?.plyr?.play();
|
||||
// });
|
||||
// }
|
||||
// }, [ref, props.source]);
|
||||
|
||||
return
|
||||
<video
|
||||
ref={usePlyr(ref, {
|
||||
...useHls('https://content.jwplatform.com/manifests/vM7nH0Kl.m3u8')
|
||||
})}
|
||||
className="plyr-react plyr"
|
||||
id="plyr"
|
||||
>
|
||||
</video>
|
||||
|
||||
});
|
||||
|
||||
|
||||
export default Plyr2
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import slugify from 'slugify';
|
||||
import { APITypes } from "plyr-react";
|
||||
import { IVod } from '@/lib/vods';
|
||||
import { getTimestampsForVod } from '@/lib/timestamps';
|
||||
|
||||
interface ITaggerProps {
|
||||
props: {
|
||||
videoRef: APITypes,
|
||||
vod: IVod
|
||||
}
|
||||
}
|
||||
|
||||
const playheadTimestamp = () => {
|
||||
// return Alpine.store('player').formatTime(Alpine.store('player').seconds);
|
||||
};
|
||||
|
||||
const displayedTimestamps = () => {
|
||||
return (!!selectedTag.id) ? timestamps.filter(ts => ts.tagName === selectedTag.name) : timestamps;
|
||||
};
|
||||
|
||||
const truncateString = (str, maxLength) => {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, maxLength - 1) + '…';
|
||||
};
|
||||
|
||||
const getTagVodRelations = (page = 1) => {
|
||||
setIsLoading(true);
|
||||
return fetch(`${window.backend}/api/tag-vod-relations?populate=*&filters[vod][id][$eq]=${vodId}&pagination[page]=${page}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
const tvrs = json.data.map((tvr) => formatTagVodRelation(tvr));
|
||||
setTagVodRelations((prevTVRs) => [...prevTVRs, ...tvrs]);
|
||||
|
||||
const totalPages = json.meta.pagination.pageCount;
|
||||
const nextPage = page + 1;
|
||||
|
||||
if (nextPage <= totalPages) {
|
||||
window.setTimeout(() => { getTagVodRelations(nextPage); }, 500);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setErrors((prevErrors) => [...prevErrors, 'Unable to download tag list.']);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// const formatTagVodRelation = (tvr) => {
|
||||
// if (!tvr?.attributes?.tag?.data) throw new Error(`cannot format tvr id ${tvr.id} because tvr.attributes.tag.data is undefined`);
|
||||
// const id = tvr.id;
|
||||
// const name = tvr.attributes.tag.data.attributes.name;
|
||||
// const tagId = tvr.attributes.tag.data.id;
|
||||
// const vodId = tvr.attributes.vod.data.id;
|
||||
// const votes = tvr.attributes.votes;
|
||||
// const creatorId = tvr.attributes.creatorId;
|
||||
// const createdAt = tvr.attributes.createdAt;
|
||||
// const dup = false;
|
||||
// return { id, name, vodId, tagId, votes, creatorId, createdAt, dup };
|
||||
// };
|
||||
|
||||
const createTag = (name) => {
|
||||
const data = {
|
||||
name: name
|
||||
};
|
||||
setIsLoading(true);
|
||||
fetch(`${window.backend}/api/tags`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ data }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${Alpine.store('auth').jwt}`
|
||||
},
|
||||
}).then((res) => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Other methods like onThumbUpTs, onThumbDownTs, voteTs, etc. should be similarly converted.
|
||||
|
||||
|
||||
export default function Tagger({ videoRef }) {
|
||||
|
||||
const [bti, setBti] = useState(null);
|
||||
const [tagVodRelations, setTagVodRelations] = useState([]);
|
||||
const [tagSuggestions, setTagSuggestions] = useState([]);
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [selectedTag, setSelectedTag] = useState({});
|
||||
const [isTsLoading, setIsTsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [context, setContext] = useState('');
|
||||
const [timestamps, setTimestamps] = useState([]);
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// getTimestampsForVod(vod.id)
|
||||
// .then(() => {
|
||||
// return getTagVodRelations();
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>VOD ID: @todo</div>
|
||||
<div>Playhead Timestamp: {playheadTimestamp()}</div>
|
||||
{/* <div>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="Enter tags"
|
||||
/>
|
||||
<button onClick={() => createTag(tagsInput)}>Create Tag</button>
|
||||
</div>
|
||||
<div>
|
||||
<ul>
|
||||
{tagVodRelations.map((tvr) => (
|
||||
<li key={tvr.id}>{tvr.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<ul>
|
||||
{displayedTimestamps().map((ts) => (
|
||||
<li key={ts.id}>{ts.tagName}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -9,7 +9,7 @@ export interface IToyProps {
|
|||
}
|
||||
|
||||
export interface IToysListsProps {
|
||||
vtuber: IVTuber;
|
||||
vtuber: IVtuber;
|
||||
toys: IToys;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
|
@ -23,16 +23,40 @@ export interface IToysListsProps {
|
|||
// }
|
||||
|
||||
|
||||
|
||||
export function ToysListHeading({ slug, displayName }: { slug: string, displayName: string }): React.JSX.Element {
|
||||
return (
|
||||
<div className='box'>
|
||||
<h3 className='title'>
|
||||
<Link href={`/vt/${slug}`}>{displayName}'s</Link> Toys
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// export interface IToy {
|
||||
// id: number;
|
||||
// tags: ITag[];
|
||||
// linkTag: ITag;
|
||||
// make: string;
|
||||
// model: string;
|
||||
// aspectRatio: string;
|
||||
// image2: string;
|
||||
// }
|
||||
|
||||
export function ToyItem({ toy }: IToyProps) {
|
||||
const displayName = `${toy.make} ${toy.model}`;
|
||||
// if (!toy?.linkTag) return <div><span className='mr-2'>toy.linkTag is missing which is a problem</span><br/></div>
|
||||
return (
|
||||
<div className="column is-half-mobile is-one-quarter-tablet is-one-fifth-desktop is-1-widescreen">
|
||||
|
||||
<Link href={`/tags/${toy.linkTag.name}`}>
|
||||
<figure style={{ position: 'relative', width: '100px', height: '100px' }}>
|
||||
<Image
|
||||
src={toy.image}
|
||||
src={toy.image2}
|
||||
alt={displayName}
|
||||
objectFit='contain'
|
||||
|
||||
fill
|
||||
/>
|
||||
</figure>
|
||||
|
@ -45,9 +69,10 @@ export function ToyItem({ toy }: IToyProps) {
|
|||
export function ToysList({ vtuber, toys, page = 1, pageSize = 24 }: IToysListsProps) {
|
||||
return (
|
||||
<div className='section'>
|
||||
{/* <pre><code>{JSON.stringify(toys, null, 2)} toys:{toys.data.length} page:{page} pageSize:{pageSize}</code></pre> */}
|
||||
{/* <pre><code>{JSON.stringify(toys, null, 2)} toys:{toys.data.length} page:{page} pageSize:{pageSize}</code></pre> */}
|
||||
<div className="columns is-mobile is-multiline">
|
||||
{toys.data.map((toy: IToy) => (
|
||||
// <p className='mr-3'>{JSON.stringify(toy, null, 2)}</p>
|
||||
<ToyItem key={toy.id} toy={toy} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import * as React from 'react'
|
||||
import videojs from 'video.js'
|
||||
import 'video.js/dist/video-js.css'
|
||||
|
||||
export const useVideoJS = (videoJsOptions: any) => {
|
||||
const videoNode = React.useRef(null)
|
||||
const player = React.useRef<any>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
player.current = videojs(videoNode.current, videoJsOptions)
|
||||
|
||||
return () => {
|
||||
player.current.dispose()
|
||||
}
|
||||
}, [changedKey])
|
||||
|
||||
const Video = React.useCallback(
|
||||
({children, ...props}) => {
|
||||
return (
|
||||
<div data-vjs-player key={changedKey}>
|
||||
<video ref={videoNode} className="video-js" {...props}>
|
||||
{children}
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[changedKey],
|
||||
)
|
||||
return {Video, player: player.current}
|
||||
}
|
|
@ -1,243 +1,169 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import 'vidstack/styles/defaults.css';
|
||||
import 'vidstack/styles/community-skin/video.css';
|
||||
import Hls from "hls.js";
|
||||
import { IVod } from '@/lib/vods';
|
||||
import { buildIpfsUrl, buildPatronIpfsUrl } from '@/lib/ipfs';
|
||||
import {
|
||||
type MediaSrc,
|
||||
type MediaPlayerElement,
|
||||
type MediaLoadedMetadataEvent,
|
||||
type MediaStalledEvent,
|
||||
type MediaPlayEvent,
|
||||
type MediaPlayFailEvent,
|
||||
type MediaCanPlayEvent,
|
||||
type MediaCanLoadEvent,
|
||||
type MediaLoadStartEvent,
|
||||
MediaProviderChangeEvent,
|
||||
MediaSourceChangeEvent,
|
||||
MediaSourcesChangeEvent,
|
||||
MediaAbortEvent,
|
||||
MediaErrorEvent,
|
||||
MediaSuspendEvent,
|
||||
LogEvent,
|
||||
} from 'vidstack'
|
||||
import { MediaCommunitySkin, MediaOutlet, MediaPlayer, MediaPoster, useMediaStore } from '@vidstack/react';
|
||||
import styles from '@/assets/styles/player.module.css'
|
||||
import { IAuthData, useAuth } from '@/components/auth'
|
||||
import { APITypes, PlyrOptions, PlyrSource } from "plyr-react";
|
||||
import { UnsupportedHlsMessage } from '@/components/custom-hls-player';
|
||||
import "plyr-react/plyr.css";
|
||||
import { CustomPlyrInstance } from '@/components/custom-hls-player';
|
||||
import { useAuth } from '@/components/auth';
|
||||
import { buildMuxUrl, getVodTitle } from './vod-page';
|
||||
import { VideoSourceSelector } from '@/components/video-source-selector'
|
||||
import { buildIpfsUrl } from '@/lib/ipfs';
|
||||
|
||||
|
||||
interface PlayerProps {
|
||||
vod: IVod;
|
||||
}
|
||||
|
||||
function buildMuxStoryboardUrl(playbackId: string, jwt: string) {
|
||||
return `https://image.mux.com/${playbackId}/storyboard.vtt?token=${jwt}`
|
||||
}
|
||||
|
||||
|
||||
export function VideoPlayer({ vod }: PlayerProps) {
|
||||
const player = useRef<MediaPlayerElement>(null);
|
||||
const { paused } = useMediaStore(player);
|
||||
// const { authData } = useAuth()
|
||||
const authData: IAuthData = {
|
||||
user: null,
|
||||
accessToken: null
|
||||
}
|
||||
const [isSrc, setIsSrc] = useState(true)
|
||||
const [is240, setIs240] = useState(false)
|
||||
const [isMux, setIsMux] = useState(true)
|
||||
|
||||
const [sources, setSources] = useState<MediaSrc[]>(() => {
|
||||
let updatedSources: MediaSrc[] = []
|
||||
if (isSrc && vod.videoSrcHash) {
|
||||
updatedSources.push({
|
||||
src: buildIpfsUrl(vod.videoSrcHash),
|
||||
type: 'video/mp4'
|
||||
});
|
||||
async function getMuxPlaybackTokens(playbackId: string, jwt: string): Promise<{ playbackToken: string, storyboardToken: string }> {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/mux-asset/secure?id=${playbackId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${jwt}`
|
||||
}
|
||||
if (is240 && vod.video240Hash) {
|
||||
updatedSources.push({
|
||||
src: buildIpfsUrl(vod.video240Hash),
|
||||
type: 'video/mp4'
|
||||
})
|
||||
}
|
||||
if (isMux && !!vod.muxAsset?.playbackId) {
|
||||
updatedSources.push({
|
||||
src: vod.muxAsset.playbackId,
|
||||
type: 'video/mux'
|
||||
})
|
||||
}
|
||||
if (isSrc && authData?.accessToken) {
|
||||
updatedSources.push({
|
||||
src: buildPatronIpfsUrl(vod.video240Hash, authData.accessToken),
|
||||
type: 'video/mp4'
|
||||
})
|
||||
}
|
||||
if (is240 && authData?.accessToken) {
|
||||
updatedSources.push({
|
||||
src: buildPatronIpfsUrl(vod.videoSrcHash, authData.accessToken),
|
||||
type: 'video/mp4'
|
||||
})
|
||||
}
|
||||
return updatedSources
|
||||
})
|
||||
// [{
|
||||
// src: 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4',
|
||||
// type: 'video/mp4'
|
||||
// }])
|
||||
const json = await res.json()
|
||||
|
||||
// useEffect(() => {
|
||||
return {
|
||||
playbackToken: json.playbackToken,
|
||||
storyboardToken: json.storyboardToken
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function buildPlyrSourceInfo(title: string, source1080: string, source240?: string): PlyrSource {
|
||||
const sourceInfo: PlyrSource = {
|
||||
type: 'video',
|
||||
title: title,
|
||||
sources: [
|
||||
{
|
||||
src: source1080,
|
||||
type: 'video/mp4',
|
||||
size: 1080,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (source240) {
|
||||
sourceInfo.sources.push({
|
||||
src: source240,
|
||||
type: 'video/mp4',
|
||||
size: 240,
|
||||
});
|
||||
}
|
||||
|
||||
return sourceInfo;
|
||||
}
|
||||
|
||||
|
||||
export function VideoPlayer({ vod }: PlayerProps): React.JSX.Element {
|
||||
const title: string = getVodTitle(vod);
|
||||
|
||||
const { authData } = useAuth()
|
||||
const [selectedVideoSource, setSelectedVideoSource] = useState('')
|
||||
const [isEntitledToCDN, setIsEntitledToCDN] = useState(false)
|
||||
const [hlsSource, setHlsSource] = useState<string>('')
|
||||
const [sourceInfo, setSourceInfo] = useState<PlyrSource>(null)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const [storyboardUrl, setStoryboardUrl] = useState<string>('https://image.mux.com/SbVSxekprYVp9owHr8OjowRAlcIk7fuXcVNGZZr006XE/storyboard.vtt?token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InFhUnd4OGQ2R2d3TnJUMDBSMzkxNHpTTkJOS1ByS2tnZmhIcFY5MDFwTUlYZyJ9.eyJleHAiOjE2OTUxODk0OTgsImF1ZCI6InMiLCJzdWIiOiJTYlZTeGVrcHJZVnA5b3dIcjhPam93UkFsY0lrN2Z1WGNWTkdaWnIwMDZYRSJ9.efNIFyBpTL11rMRITSuz_JbPcb_wo3lJeNjQtTSxn4CwR6T_x9kcgh_sVN0gQC59YfI-0QNLgnwdaXgVUEkpJrCmxoESnmLfu28Y57xv676KkQE3QKcqtpn0X88VFKA0nxEJB92topCt-9qEH3Wdl1TO4nTwNBwp_4hVTNLld1iF_W7CVNqf60Pt9p6xF1MdOnZSN7GtkZUNvBr_7dC_e-4VBxJy1c9liE-_jw6YYg8yRXwETffOXJDS_iMB-C-5Bh8QxQi-454cBEZIcczRUER3Nj0GD9HoAnvsis18Gtt8ywfl8fezlZhHFJno-1L_sy-1aXk8eKmNu462YSG2jA')
|
||||
// const [plyrOptions, setPlyrOptions] = useState<PlyrOptions | null>(null)
|
||||
|
||||
const videoRef = useRef<APITypes>(null);
|
||||
const supported = Hls.isSupported();
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
const token = authData?.accessToken;
|
||||
const playbackId = vod?.muxAsset?.playbackId;
|
||||
const title = vod.announceTitle || vod.title || `${vod.vtuber.displayName} ${vod.date}`;
|
||||
|
||||
if (token) setIsEntitledToCDN(true);
|
||||
|
||||
if (selectedVideoSource === 'Mux') {
|
||||
if (!!token && !!playbackId) {
|
||||
|
||||
try {
|
||||
getMuxPlaybackTokens(vod.muxAsset.playbackId, token)
|
||||
.then((tokens) => {
|
||||
setHlsSource(buildMuxUrl(playbackId, tokens.playbackToken));
|
||||
setSourceInfo({
|
||||
type: 'video',
|
||||
title: title,
|
||||
sources: [],
|
||||
})
|
||||
setStoryboardUrl(buildMuxStoryboardUrl(playbackId, tokens.storyboardToken))
|
||||
});
|
||||
}
|
||||
|
||||
catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// setSources(updatedSources);
|
||||
// }, [isSrc, is240, isMux, authData]);
|
||||
} else if (selectedVideoSource === 'B2') {
|
||||
setHlsSource(vod.videoSrcB2.cdnUrl);
|
||||
setSourceInfo(buildPlyrSourceInfo(title, vod.videoSrcB2.cdnUrl))
|
||||
|
||||
|
||||
} else if (selectedVideoSource === 'IPFS') {
|
||||
setHlsSource('');
|
||||
setSourceInfo(buildPlyrSourceInfo(title, buildIpfsUrl(vod.videoSrcHash), buildIpfsUrl(vod.video240Hash)))
|
||||
}
|
||||
|
||||
}, [selectedVideoSource, authData, vod]);
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// if (vod.video240Hash) {
|
||||
// const newSource: MediaSrc = {
|
||||
// src: buildIpfsUrl(vod.video240Hash),
|
||||
// type: 'video/mp4'
|
||||
// };
|
||||
// setSources(prevSources => [...prevSources, newSource])
|
||||
// }
|
||||
// }, [vod.video240Hash]);
|
||||
// if (!storyboardUrl) return;
|
||||
// setPlyrOptions(
|
||||
// {
|
||||
// previewThumbnails: {
|
||||
// enabled: true,
|
||||
// src: storyboardUrl
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
// }, [storyboardUrl])
|
||||
|
||||
|
||||
if (!isClient) return <></>
|
||||
if (!supported) return UnsupportedHlsMessage();
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<MediaPlayer
|
||||
title="testing, 123"
|
||||
src={sources}
|
||||
aspect-ratio="16/9"
|
||||
className={styles.fpMediaPlayer}
|
||||
crossorigin=""
|
||||
load="eager"
|
||||
autoplay={true}
|
||||
onLoadedMetadata={(event: MediaLoadedMetadataEvent) => {
|
||||
console.log(event.trigger)
|
||||
{/* {JSON.stringify(vod, null, 2)} */}
|
||||
<CustomPlyrInstance
|
||||
ref={videoRef}
|
||||
source={sourceInfo}
|
||||
hlsSource={hlsSource}
|
||||
mainColor={vod.vtuber.themeColor}
|
||||
// options={null}
|
||||
options={{
|
||||
previewThumbnails: {
|
||||
enabled: !!storyboardUrl,
|
||||
src: storyboardUrl || ''
|
||||
}
|
||||
}}
|
||||
onStalled={(event: MediaStalledEvent) => {
|
||||
console.log('VIDEO STALLED !!!! 1')
|
||||
}}
|
||||
onPlay={(event: MediaPlayEvent) => {
|
||||
console.log('media is play')
|
||||
}}
|
||||
onPlayFail={(event: MediaPlayFailEvent) => {
|
||||
console.log('media play failed')
|
||||
}}
|
||||
onCanPlay={(event: MediaCanPlayEvent) => {
|
||||
console.log('media can play. !')
|
||||
}}
|
||||
onCanLoad={(event: MediaCanLoadEvent) => {
|
||||
console.log('media can load ~')
|
||||
}}
|
||||
onLoadStart={(event: MediaLoadStartEvent) => {
|
||||
console.log('media load has started ---')
|
||||
}}
|
||||
onProviderChange={(event: MediaProviderChangeEvent) => {
|
||||
console.log('privder has changed')
|
||||
}}
|
||||
onSourceChange={(event: MediaSourceChangeEvent) => {
|
||||
console.log('source has changed')
|
||||
}}
|
||||
onSourcesChange={(event: MediaSourcesChangeEvent) => {
|
||||
console.log('media sourceS (plural) have changed!')
|
||||
}}
|
||||
onAbort={(event: MediaAbortEvent) => {
|
||||
console.log('media abort!')
|
||||
}}
|
||||
onError={(event: MediaErrorEvent) => {
|
||||
console.log('media error!!!!')
|
||||
console.log(event)
|
||||
}}
|
||||
onSuspend={(event: MediaSuspendEvent) => {
|
||||
console.log('media suspend!! !``')
|
||||
}}
|
||||
// onVdsLog={(event: LogEvent) => {
|
||||
// console.log('vidstack log event! ')
|
||||
// console.log(event)
|
||||
// }}
|
||||
|
||||
|
||||
>
|
||||
<MediaOutlet>
|
||||
</MediaOutlet>
|
||||
<MediaPoster>
|
||||
</MediaPoster>
|
||||
<MediaCommunitySkin>
|
||||
</MediaCommunitySkin>
|
||||
</MediaPlayer>
|
||||
|
||||
|
||||
{/* <MediaPlayer
|
||||
title="Sprite Fight"
|
||||
src="https://stream.mux.com/VZtzUzGRv02OhRnZCxcNg49OilvolTqdnFLEqBsTwaxU/low.mp4"
|
||||
poster="https://image.mux.com/VZtzUzGRv02OhRnZCxcNg49OilvolTqdnFLEqBsTwaxU/thumbnail.webp?time=268&width=980"
|
||||
thumbnails="https://media-files.vidstack.io/sprite-fight/thumbnails.vtt"
|
||||
aspectRatio={16 / 9}
|
||||
crossorigin=""
|
||||
>
|
||||
<MediaOutlet>
|
||||
<MediaPoster
|
||||
alt="Girl walks into sprite gnomes around her friend on a campfire in danger!"
|
||||
/>
|
||||
<track
|
||||
src="https://media-files.vidstack.io/sprite-fight/subs/english.vtt"
|
||||
label="English"
|
||||
srcLang="en-US"
|
||||
kind="subtitles"
|
||||
default
|
||||
/>
|
||||
<track
|
||||
src="https://media-files.vidstack.io/sprite-fight/chapters.vtt"
|
||||
srcLang="en-US"
|
||||
kind="chapters"
|
||||
default
|
||||
/>
|
||||
</MediaOutlet>
|
||||
<MediaCommunitySkin />
|
||||
</MediaPlayer> */}
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
<p>## paused: {(paused) ? <span>yes</span> : <span>no</span>}</p>
|
||||
<p>## Auth data</p>
|
||||
<p>{JSON.stringify(authData, null, 2)}</p>
|
||||
{/* <p>## thumbnail</p>
|
||||
{vod.thumbnail} */}
|
||||
<p>## sources</p>
|
||||
{JSON.stringify(sources, null, 2)}
|
||||
{/* <p>## vod</p>
|
||||
{JSON.stringify(vod, null, 2)} */}
|
||||
</code>
|
||||
</pre>
|
||||
{/* <media-player
|
||||
title="Sprite Fight"
|
||||
src="https://stream.mux.com/VZtzUzGRv02OhRnZCxcNg49OilvolTqdnFLEqBsTwaxU/low.mp4"
|
||||
poster="https://image.mux.com/VZtzUzGRv02OhRnZCxcNg49OilvolTqdnFLEqBsTwaxU/thumbnail.webp?time=268&width=980"
|
||||
thumbnails="https://media-files.vidstack.io/sprite-fight/thumbnails.vtt"
|
||||
aspect-ratio="16/9"
|
||||
crossorigin
|
||||
>
|
||||
<media-outlet>
|
||||
<media-poster
|
||||
alt="Girl walks into sprite gnomes around her friend on a campfire in danger!"
|
||||
></media-poster>
|
||||
<track
|
||||
src="https://media-files.vidstack.io/sprite-fight/subs/english.vtt"
|
||||
label="English"
|
||||
srclang="en-US"
|
||||
kind="subtitles"
|
||||
default
|
||||
/>
|
||||
<track
|
||||
src="https://media-files.vidstack.io/sprite-fight/chapters.vtt"
|
||||
srclang="en-US"
|
||||
kind="chapters"
|
||||
default
|
||||
/>
|
||||
</media-outlet>
|
||||
<media-community-skin></media-community-skin>
|
||||
</media-player> */}
|
||||
|
||||
></CustomPlyrInstance>
|
||||
<VideoSourceSelector
|
||||
isMux={!!vod?.muxAsset?.playbackId}
|
||||
isB2={!!vod?.videoSrcB2?.cdnUrl}
|
||||
isIPFS={!!vod?.videoSrcHash}
|
||||
isEntitledToCDN={isEntitledToCDN}
|
||||
selectedVideoSource={selectedVideoSource}
|
||||
setSelectedVideoSource={setSelectedVideoSource}
|
||||
></VideoSourceSelector>
|
||||
{/* <p>selectedOption:{JSON.stringify(selectedVideoSource, null, 2)}</p>
|
||||
<p>hlsSource:{JSON.stringify(hlsSource, null, 2)}</p>
|
||||
<p>sourceInfo:{JSON.stringify(sourceInfo, null, 2)}</p>
|
||||
<p>storyboardUrl:{JSON.stringify(storyboardUrl, null, 2)}</p> */}
|
||||
{/* <p>plyrOptions:{JSON.stringify(plyrOptions, null, 2)}</p> */}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
'use client';
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { faPatreon } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faGlobe } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface IVSSProps {
|
||||
isMux: boolean;
|
||||
isB2: boolean;
|
||||
isIPFS: boolean;
|
||||
isEntitledToCDN: boolean;
|
||||
setSelectedVideoSource: (option: string) => void;
|
||||
selectedVideoSource: string;
|
||||
}
|
||||
|
||||
export function VideoSourceSelector({
|
||||
isMux,
|
||||
isB2,
|
||||
isIPFS,
|
||||
isEntitledToCDN,
|
||||
selectedVideoSource,
|
||||
setSelectedVideoSource,
|
||||
}: IVSSProps): React.JSX.Element {
|
||||
|
||||
// Check for user's entitlements and saved preference when component mounts
|
||||
useEffect(() => {
|
||||
// Function to determine the best video source based on entitlements and preferences
|
||||
const determineBestVideoSource = () => {
|
||||
if (isEntitledToCDN) {
|
||||
if (selectedVideoSource === 'Mux' && isMux) {
|
||||
return 'Mux';
|
||||
} else if (selectedVideoSource === 'B2' && isB2) {
|
||||
return 'B2';
|
||||
}
|
||||
}
|
||||
// If the user doesn't have entitlements or their preference is not available, default to IPFS
|
||||
if (isIPFS) {
|
||||
return 'IPFS';
|
||||
}
|
||||
// If no sources are available, return an empty string
|
||||
return '';
|
||||
};
|
||||
|
||||
// Load the user's saved preference from storage (e.g., local storage)
|
||||
const savedPreference = localStorage.getItem('videoSourcePreference');
|
||||
|
||||
// Check if the saved preference is valid based on entitlements and available sources
|
||||
if (savedPreference === 'Mux' && isMux && isEntitledToCDN) {
|
||||
setSelectedVideoSource('Mux');
|
||||
} else if (savedPreference === 'B2' && isB2 && isEntitledToCDN) {
|
||||
setSelectedVideoSource('B2');
|
||||
} else {
|
||||
// Determine the best video source if the saved preference is invalid or not available
|
||||
const bestSource = determineBestVideoSource();
|
||||
setSelectedVideoSource(bestSource);
|
||||
}
|
||||
}, [isMux, isB2, isIPFS, isEntitledToCDN]);
|
||||
|
||||
// Handle button click to change the selected video source
|
||||
const handleSourceClick = (source: string) => {
|
||||
if ((source === 'Mux' && isMux && isEntitledToCDN) || (source === 'B2' && isB2 && isEntitledToCDN) || source === 'IPFS') {
|
||||
setSelectedVideoSource(source);
|
||||
// Save the user's preference to storage (e.g., local storage)
|
||||
localStorage.setItem('videoSourcePreference', source);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="box">
|
||||
<nav className="level is-text-centered">
|
||||
<div className="nav-heading">
|
||||
Video Source Selector
|
||||
</div>
|
||||
{(!isMux && !isB2 && !isIPFS) && <div className="nav-item">
|
||||
<div className="notification is-danger">
|
||||
<span>No video sources available</span>
|
||||
</div>
|
||||
</div>}
|
||||
{(isMux) && <div className="nav-item">
|
||||
<button onClick={() => handleSourceClick('Mux')} disabled={!isEntitledToCDN} className={`button ${selectedVideoSource === 'Mux' && 'is-active'}`}>
|
||||
<span className="icon">
|
||||
<FontAwesomeIcon icon={faPatreon} className="fab fa-patreon" />
|
||||
</span>
|
||||
<span>CDN 1</span>
|
||||
</button>
|
||||
</div>}
|
||||
{(isB2) && <div className="nav-item">
|
||||
<button onClick={() => handleSourceClick('B2')} disabled={!isEntitledToCDN} className={`button ${selectedVideoSource === 'B2' && 'is-active'}`}>
|
||||
<span className="icon">
|
||||
<FontAwesomeIcon icon={faPatreon} className="fab fa-patreon" />
|
||||
</span>
|
||||
<span>CDN 2</span>
|
||||
</button>
|
||||
</div>}
|
||||
{(isIPFS) && <div className="nav-item">
|
||||
<button onClick={() => handleSourceClick('IPFS')} className={`button ${(selectedVideoSource === 'IPFS') && 'is-active'}`}>
|
||||
<span className="icon">
|
||||
<FontAwesomeIcon icon={faGlobe} className="fas fa-globe" />
|
||||
</span>
|
||||
<span>IPFS</span>
|
||||
</button>
|
||||
</div>}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="box">
|
||||
<p><i>Tags & Timestamps temporarily disabled</i></p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useRef } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import 'video.js/dist/video-js.css';
|
||||
|
||||
export const VideoJS = (props) => {
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = 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');
|
||||
videoRef.current.appendChild(videoElement);
|
||||
|
||||
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;
|
||||
|
||||
player.autoplay(options.autoplay);
|
||||
player.src(options.sources);
|
||||
}
|
||||
}, [options, videoRef]);
|
||||
|
||||
// Dispose the Video.js player when the functional component unmounts
|
||||
React.useEffect(() => {
|
||||
const player = playerRef.current;
|
||||
|
||||
return () => {
|
||||
if (player && !player.isDisposed()) {
|
||||
player.dispose();
|
||||
playerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playerRef]);
|
||||
|
||||
return (
|
||||
<div data-vjs-player>
|
||||
<div ref={videoRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VideoJS;
|
|
@ -6,12 +6,13 @@ import { getSafeDate, getDateFromSafeDate } from '@/lib/dates';
|
|||
import { IVtuber } from '@/lib/vtubers';
|
||||
import Image from 'next/image'
|
||||
import { LocalizedDate } from '@/components/localized-date'
|
||||
import { IMuxAsset } from "@/lib/types";
|
||||
|
||||
interface IVodCardProps {
|
||||
id: string;
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
muxAsset?: string;
|
||||
muxAsset?: IMuxAsset;
|
||||
thumbnail?: string;
|
||||
vtuber: IVtuber; // Set the type of Vtuber to IVtuber
|
||||
}
|
||||
|
|
|
@ -8,14 +8,24 @@ import Link from 'next/link';
|
|||
import { buildIpfsUrl } from '@/lib/ipfs'
|
||||
import VodNav from '@/components/vod-nav';
|
||||
import { getTagHref } from '@/lib/tags';
|
||||
import { useState } from 'react';
|
||||
import { ITagVodRelation } from '@/lib/tag-vod-relations';
|
||||
|
||||
interface PageProps {
|
||||
safeDate: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function buildMuxUrl(playbackId: string) {
|
||||
return `https://stream.mux.com/${playbackId}.m3u8`
|
||||
export function getVodTitle(vod: IVod): string {
|
||||
return vod.title || vod.announceTitle || vod.vtuber.displayName
|
||||
}
|
||||
|
||||
export function buildMuxUrl(playbackId: string, token: string) {
|
||||
return `https://stream.mux.com/${playbackId}.m3u8?token=${token}`
|
||||
}
|
||||
|
||||
export function buildMuxThumbnailUrl(playbackId: string, token: string) {
|
||||
return `https://image.mux.com/${playbackId}/storyboard.vtt?token=${token}`
|
||||
}
|
||||
|
||||
|
||||
|
@ -23,14 +33,17 @@ export default async function VodPage({ safeDate, slug }: PageProps) {
|
|||
const date: Date = getDateFromSafeDate(safeDate)
|
||||
const vod = await getVodForDate(date)
|
||||
const localizedDate = date.toLocaleDateString()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<div className="block">
|
||||
<div className="box" style={{ overflow: 'hidden' }}>
|
||||
{/* {JSON.stringify(vod, null, 2)} */}
|
||||
<VideoPlayer
|
||||
vod={vod}
|
||||
></VideoPlayer>
|
||||
|
||||
<h3 className="subtitle is-3">
|
||||
{vod.announceTitle || vod.title}
|
||||
</h3>
|
||||
|
@ -40,32 +53,36 @@ export default async function VodPage({ safeDate, slug }: PageProps) {
|
|||
<p className='mb-2'>{localizedDate}</p>
|
||||
{vod.note && (<div className='notification'>{vod.note}</div>)}
|
||||
<div className='tags'>
|
||||
{vod.tagVodRelations.map((tvr) => (
|
||||
{vod.tagVodRelations.map((tvr: ITagVodRelation) => (
|
||||
<Link href={getTagHref(tvr.tag.name)}>
|
||||
<span className='tag'>{tvr.tag.name}</span>
|
||||
<span className='tag mr-1'>{tvr.tag.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(vod.videoSrcHash || vod.video240Hash) && (
|
||||
<>
|
||||
<h4 className='subtitle is-4'>IPFS Content IDs</h4>
|
||||
<ul>
|
||||
{vod.videoSrcHash && (
|
||||
<li>
|
||||
<span>
|
||||
Source: <span className="tag">{vod.videoSrcHash}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{vod.video240Hash && (
|
||||
<li>
|
||||
<span>
|
||||
240p: <span className="tag">{vod.video240Hash}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4 className='subtitle is-4'>IPFS Content IDs</h4>
|
||||
<ul>
|
||||
{vod.videoSrcHash && (
|
||||
<li>
|
||||
<span>
|
||||
Source: <span className="tag">{vod.videoSrcHash}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{vod.video240Hash && (
|
||||
<li>
|
||||
<span>
|
||||
240p: <span className="tag">{vod.video240Hash}</span>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* <pre className='mt-3'>
|
||||
<code>
|
||||
{JSON.stringify(vod, null, 2)}
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import React from 'react'
|
||||
import VodCard from './vod-card';
|
||||
import { getVods, getVodsForVtuber, IVods, IVod } from '../lib/vods';
|
||||
import { IVtuber } from '@/lib/vtubers';
|
||||
import { getVtuberById } from '../lib/vtubers';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import VodCard from './vod-card';
|
||||
import { IVtuber } from '@/lib/vtubers';
|
||||
import { IVods, IVod } from '@/lib/vods';
|
||||
import { getVodTitle } from './vod-page';
|
||||
|
||||
interface VodsListProps {
|
||||
vtuber: IVtuber;
|
||||
vods: IVods;
|
||||
vods: IVod[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
@ -26,8 +25,8 @@ export function VodsListHeading({ slug, displayName }: { slug: string, displayNa
|
|||
}
|
||||
|
||||
|
||||
export function VodsList({ vtuber, vods, page = 1, pageSize = 24 }: VodsListProps) {
|
||||
if (!vtuber) return <div>vtuber is not defined. vtuber:{JSON.stringify(vtuber, null, 2)}</div>
|
||||
export function VodsList({ vods, page = 1, pageSize = 24 }: VodsListProps) {
|
||||
// if (!vtuber) return <div>vtuber is not defined. vtuber:{JSON.stringify(vtuber, null, 2)}</div>
|
||||
if (!vods) return <div>failed to load vods</div>;
|
||||
|
||||
// @todo [x] pagination
|
||||
|
@ -44,14 +43,14 @@ export function VodsList({ vtuber, vods, page = 1, pageSize = 24 }: VodsListProp
|
|||
|
||||
|
||||
<div className="columns is-multiline is-mobile">
|
||||
{vods.data.map((vod: IVod) => (
|
||||
{vods.map((vod: IVod) => (
|
||||
<>
|
||||
<VodCard
|
||||
id={vod.id}
|
||||
title={vod.title}
|
||||
title={getVodTitle(vod)}
|
||||
date={vod.date}
|
||||
muxAsset={vod.muxAsset}
|
||||
vtuber={vtuber}
|
||||
vtuber={vod.vtuber}
|
||||
thumbnail={vod.thumbnail}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -31,6 +31,10 @@ export default function Page() {
|
|||
};
|
||||
|
||||
const storeJwtJson = (json: IJWT) => {
|
||||
|
||||
console.log('storing jwt json')
|
||||
console.log(json)
|
||||
|
||||
// Store the JWT and other relevant data in your state management system
|
||||
const data: IAuthData = {
|
||||
accessToken: json.jwt,
|
||||
|
@ -113,6 +117,7 @@ export default function Page() {
|
|||
{errors && errors.length > 0 && (
|
||||
<DangerNotification errors={errors} />
|
||||
)}
|
||||
<p>Redirecting...</p>
|
||||
<Link href={lastVisitedPath || '/profile'}>Click here if you are not automatically redirected</Link>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -3,7 +3,7 @@ import { getVtuberBySlug } from '../lib/vtubers'
|
|||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPatreon, faTwitter, faYoutube, faTwitch, faTiktok } from "@fortawesome/free-brands-svg-icons";
|
||||
|
||||
import { projektMelodyEpoch } from '@/lib/constants';
|
||||
|
||||
export default async function Page() {
|
||||
return (
|
||||
|
@ -11,8 +11,30 @@ export default async function Page() {
|
|||
<div className="block">
|
||||
<div className="box">
|
||||
<p id="faq" className="title">Frequently Asked Questions (FAQ)</p>
|
||||
<div className="columns is-mobile is-multiline mb-6">
|
||||
<div className="column is-full">
|
||||
|
||||
|
||||
|
||||
<div className="section">
|
||||
<h3 id="vtuber" className='title is-5 mt-0'>What is a VTuber?</h3>
|
||||
<p>VTuber is a portmantou of the words Virtual and Youtuber. Originally started in Japan, VTubing uses cameras and/or motion capture technology to replicate human movement and facial expressions onto a virtual character in realtime.</p>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 id="lewdtuber" className='title is-5'>What is a Lewdtuber?</h3>
|
||||
<p>Lewdtubers are sexually explicit vtubers. ProjektMelody was the first Vtuber to livestream on Chaturbate on {projektMelodyEpoch.toDateString()}. Many more followed after her.</p>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h3 id="ipfs" className='title is-5'>What is IPFS?</h3>
|
||||
<p>Interplanetary File System (IPFS) is a new-ish technology which gives a unique address to every file. This address is called a Content ID, or CID for short. A CID can be used to request the file from the IPFS network.</p>
|
||||
<p>IPFS is a distributed, decentralized protocol with no central point of failure. IPFS provider nodes can come and go, providing file serving capacity to the network. As long as there is at least one node pinning the content you want, you can download it.</p>
|
||||
<p>There are a few ways to use IPFS. The first and easiest is to use a public gateway. These can be overloaded and slow at times, but using one is as simple as visiting a gateway URL containing the CID. One such example is <Link target="_blank" href="https://ipfs.io/ipfs/bafkreigaknpexyvxt76zgkitavbwx6ejgfheup5oybpm77f3pxzrvwpfdi"><span className='mr-1'>https://ipfs.io/ipfs/bafkreigaknpexyvxt76zgkitavbwx6ejgfheup5oybpm77f3pxzrvwpfdi</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> </p>
|
||||
<p>The second way is to use a web browser which supports IPFS, such as <Link href="https://brave.com/" target="_blank"><span className='mr-1'>Brave browser.</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link></p>
|
||||
<p>Other ways include running <Link target="_blank" href="https://docs.ipfs.io/install/ipfs-desktop/"><span className="mr-1">IPFS Desktop</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> on your computer, paired with <Link href="https://docs.ipfs.tech/install/ipfs-companion/"><span className='mr-1'>IPFS Companion</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> browser extension.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="section">
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h3 id="not-working" className="title is-5">The videos are not working? / buffering a lot?</h3>
|
||||
|
@ -21,7 +43,7 @@ export default async function Page() {
|
|||
|
||||
<p>If you want to watch a VOD but you can't stream it, download it first using <Link target="_blank" href="https://docs.ipfs.io/install/ipfs-desktop/"><span className="mr-1">IPFS Desktop</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link></p>
|
||||
|
||||
<Link target="_blank" href="https://www.youtube.com/video/bclXdKNEplw" className="button is-link">
|
||||
<Link target="_blank" href="https://www.youtube.com/video/bclXdKNEplw" className="button is-link mb-3">
|
||||
<span className="icon">
|
||||
<i className="fab fa-youtube"></i>
|
||||
</span>
|
||||
|
@ -33,7 +55,7 @@ export default async function Page() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="column is-full">
|
||||
<div className="section">
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<h3 id="not-reachable" className="title is-5 mb-5">My browser says the video is not reachable</h3>
|
||||
|
@ -50,13 +72,22 @@ export default async function Page() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="column is-full">
|
||||
<div className='section'>
|
||||
<div className="mb-3">
|
||||
<h3 id="other-luber" className="title is-5">There's a cool new Lewd Vtuber who streams on CB. Will you archive their vods?</h3>
|
||||
</div>
|
||||
<p>Yes. Futureporn aims to become the galaxy's best VTuber hentai site.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='section'>
|
||||
<div className='mb-3'>
|
||||
<h3 id='how-can-i-help' className='title is-5'>How can I help?</h3>
|
||||
<p>Bandwidth and rental fees are expensive, so Futureporn needs financial assistance to keep servers online and videos streaming.</p>
|
||||
<p><Link href="/patrons">Patrons</Link> gain access to perks like our video Content Delivery Network (CDN), and optional shoutouts on the patrons page.</p>
|
||||
<p>Additionally, help is needed <Link href="/upload">populating our archive</Link> with vods from past lewdtuber streams.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { generateFeeds } from "@/lib/rss"
|
||||
|
||||
export async function GET() {
|
||||
const { json1 } = await generateFeeds()
|
||||
const feeds = await generateFeeds()
|
||||
const options = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
return new Response(json1, options)
|
||||
return new Response(feeds.json1, options)
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
|
||||
import Link from 'next/link'
|
||||
import { generateFeeds } from '@/lib/rss'
|
||||
|
||||
export default async function Page() {
|
||||
const feeds = await generateFeeds()
|
||||
return (
|
||||
<>
|
||||
<div className="content">
|
||||
|
@ -17,9 +15,9 @@ export default async function Page() {
|
|||
<p>Don't have a RSS reader? Futureporn recommends <Link target="_blank" href="https://fraidyc.at/">Fraidycat <span className="icon"><i className="fas fa-external-link-alt"></i></span></Link></p>
|
||||
|
||||
<div className='field is-grouped'>
|
||||
<p className='control'><a className="my-5 button is-primary" href="/feed/feed.xml">ATOM</a></p>
|
||||
<p className="control"><a className="my-5 button" href="/feed/rss.xml">RSS</a></p>
|
||||
<p className='control'><a className="my-5 button" href="/feed/feed.json">JSON</a></p>
|
||||
<p className='control'><Link className="my-5 button is-primary" href="/feed/feed.xml">ATOM</Link></p>
|
||||
<p className="control"><Link className="my-5 button" href="/feed/rss.xml">RSS</Link></p>
|
||||
<p className='control'><Link className="my-5 button" href="/feed/feed.json">JSON</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,8 +3,9 @@ import Footer from "./components/footer"
|
|||
import Navbar from "./components/navbar"
|
||||
import "../assets/styles/global.sass";
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css";
|
||||
import { AuthProvider } from './components/auth'
|
||||
import type { Metadata } from 'next'
|
||||
import { AuthProvider } from './components/auth';
|
||||
import type { Metadata } from 'next';
|
||||
// import NextTopLoader from 'nextjs-toploader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Futureporn.net',
|
||||
|
@ -31,8 +32,18 @@ export default function RootLayout({
|
|||
}: Props) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
||||
<body>
|
||||
{/* <NextTopLoader
|
||||
color="#ac0722"
|
||||
initialPosition={0.08}
|
||||
crawlSpeed={200}
|
||||
height={3}
|
||||
crawl={true}
|
||||
showSpinner={false}
|
||||
easing="ease"
|
||||
speed={200}
|
||||
shadow="0 0 10px #2299DD,0 0 5px #2299DD"
|
||||
/> */}
|
||||
<AuthProvider>
|
||||
<Navbar />
|
||||
<div className="container">
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
export interface IB2File {
|
||||
url: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
cdnUrl: string;
|
||||
}
|
||||
|
||||
export function unmarshallB2File(d: any): IB2File {
|
||||
console.log(`unmarshalling b2File`)
|
||||
console.log(d)
|
||||
if (!d) return null
|
||||
return {
|
||||
url: d.attributes.url,
|
||||
key: d.attributes.key,
|
||||
uploadId: d.attributes.uploadId,
|
||||
cdnUrl: d.attributes?.cdnUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,6 +2,7 @@ export const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL
|
|||
export const siteUrl = (process.env.NODE_ENV === 'production') ? 'https://futureporn.net' : 'http://localhost:3000'
|
||||
export const patreonSupporterBenefitId = '4760169'
|
||||
export const patreonQuantumSupporterId = '10663202'
|
||||
export const patreonVideoAccessBenefitId = '13462019'
|
||||
export const skeletonHeight = '32pt'
|
||||
export const skeletonBaseColor = '#000'
|
||||
export const skeletonHighlightColor = '#000'
|
||||
|
@ -12,4 +13,6 @@ export const siteImage = 'https://futureporn.net/images/futureporn-icon.png'
|
|||
export const favicon = 'https://futureporn.net/favicon.ico'
|
||||
export const authorName = 'CJ_Clippy'
|
||||
export const authorEmail = 'cj@futureporn.net'
|
||||
export const authorLink = 'https://futureporn.net'
|
||||
export const authorLink = 'https://futureporn.net'
|
||||
export const giteaUrl = 'https://gitea.futureporn.net'
|
||||
export const projektMelodyEpoch = new Date('2020-02-07T23:21:48.000Z')
|
|
@ -1,4 +1,8 @@
|
|||
import { IContributor } from "./types";
|
||||
|
||||
export type IContributor = {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export async function getContributors(): Promise<IContributor[]> {
|
||||
try {
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { createHelia } from 'helia';
|
||||
import { MemoryDatastore } from "datastore-core";
|
||||
import { MemoryBlockstore } from "blockstore-core";
|
||||
import { LevelDatastore } from "datastore-level";
|
||||
import { LevelBlockstore } from 'blockstore-level';
|
||||
import { HeliaInstanceType } from './types';
|
||||
import { libp2pDefaults } from './libp2p';
|
||||
|
||||
let heliaInstance: HeliaInstanceType | null = null;
|
||||
export default async () => {
|
||||
// application-specific data lives in the datastore
|
||||
const datastore = new LevelDatastore(`helia-example-datastore`);
|
||||
const blockstore = new LevelBlockstore(`helia-example-blockstore`);
|
||||
// const datastore = new LevelDatastore(`helia-example-datastore-${Math.random()}`);
|
||||
// const blockstore = new LevelBlockstore(`helia-example-blockstore-${Math.random()}`);
|
||||
|
||||
if (heliaInstance != null) {
|
||||
return heliaInstance;
|
||||
}
|
||||
// @ts-expect-error - types are borked...
|
||||
heliaInstance = await createHelia({
|
||||
datastore,
|
||||
blockstore,
|
||||
libp2p: libp2pDefaults()
|
||||
});
|
||||
// addToLog("Created Helia instance");
|
||||
|
||||
return heliaInstance;
|
||||
};
|
|
@ -0,0 +1,166 @@
|
|||
/* eslint-disable no-console */
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
createContext
|
||||
} from 'react'
|
||||
|
||||
import { type UnixFS, unixfs } from '@helia/unixfs'
|
||||
import type { Helia } from '@helia/interface'
|
||||
|
||||
import getHelia from '../getHelia.ts'
|
||||
import { HeliaInstanceType } from '../types'
|
||||
|
||||
export interface HeliaContext {
|
||||
helia: HeliaInstanceType | null,
|
||||
fs: UnixFS | null,
|
||||
error: boolean | null,
|
||||
starting: boolean,
|
||||
dhtMode: 'client' | 'server' | 'unknown'
|
||||
status: 'Online' | 'Offline',
|
||||
nodeId: string | null,
|
||||
discoveredPeers: any[],
|
||||
connectedPeers: any[],
|
||||
multiaddrs: any[],
|
||||
events: string[],
|
||||
addToEventLog?: (event: string) => void
|
||||
}
|
||||
|
||||
const defaultContextValues: Omit<HeliaContext, 'addToEventLog'> = {
|
||||
helia: null,
|
||||
fs: null,
|
||||
error: false,
|
||||
starting: true,
|
||||
dhtMode: 'unknown',
|
||||
status: 'Offline',
|
||||
nodeId: null,
|
||||
discoveredPeers: [],
|
||||
connectedPeers: [],
|
||||
multiaddrs: [],
|
||||
events: []
|
||||
}
|
||||
export const HeliaContext = createContext<HeliaContext>(defaultContextValues)
|
||||
|
||||
export const HeliaProvider = ({ children }) => {
|
||||
const [helia, setHelia] = useState<HeliaContext['helia']>(null)
|
||||
const [fs, setFs] = useState<HeliaContext['fs']>(null)
|
||||
const [starting, setStarting] = useState<HeliaContext['starting']>(true)
|
||||
const [error, setError] = useState<HeliaContext['error']>(null)
|
||||
const [dhtMode, setDhtMode] = useState<HeliaContext['dhtMode']>('unknown')
|
||||
const [status, setStatus] = useState<HeliaContext['status']>('Offline')
|
||||
const [nodeId, setNodeId] = useState<HeliaContext['nodeId']>(null)
|
||||
const [discoveredPeers, setDiscoveredPeers] = useState<HeliaContext['discoveredPeers']>([])
|
||||
const [connectedPeers, setConnectedPeers] = useState<HeliaContext['connectedPeers']>([])
|
||||
const [multiaddrs, setMultiaddrs] = useState<HeliaContext['multiaddrs']>([])
|
||||
const [events, setEvents] = useState<HeliaContext['events']>([])
|
||||
|
||||
const addToEventLog = useCallback((event: string) => {
|
||||
setEvents((prev) => [...prev, event])
|
||||
}, [])
|
||||
|
||||
const startHelia = useCallback(async () => {
|
||||
if (helia) {
|
||||
addToEventLog('helia already started');
|
||||
} else {
|
||||
|
||||
}
|
||||
}, [])
|
||||
const onBeforeUnload = useCallback(async () => {
|
||||
if (helia != null) {
|
||||
addToEventLog('Stopping Helia')
|
||||
await helia.stop()
|
||||
}
|
||||
}, [helia])
|
||||
|
||||
useEffect(() => {
|
||||
if (helia != null) {
|
||||
return
|
||||
}
|
||||
async function effect() {
|
||||
try {
|
||||
addToEventLog('Starting Helia')
|
||||
const helia = await getHelia()
|
||||
setHelia(helia)
|
||||
setFs(unixfs(helia))
|
||||
setNodeId(helia.libp2p.peerId.toString())
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
effect()
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateInfo = useCallback(async () => {
|
||||
if (helia == null) {
|
||||
return
|
||||
}
|
||||
setDhtMode(await helia.libp2p.services.dht.getMode())
|
||||
setStatus(helia.libp2p.isStarted() ? 'Online' : 'Offline')
|
||||
setStarting(false)
|
||||
}, [helia])
|
||||
|
||||
useEffect(() => {
|
||||
if (helia == null || fs == null) {
|
||||
return
|
||||
}
|
||||
(window as any).helia = helia;
|
||||
(window as any).heliaFs = fs;
|
||||
(window as any).discoveredPeers = discoveredPeers;
|
||||
|
||||
let timeoutId: any = null
|
||||
const refreshFn = async () => {
|
||||
updateInfo()
|
||||
timeoutId = setTimeout(refreshFn, 1000)
|
||||
}
|
||||
refreshFn()
|
||||
|
||||
helia.libp2p.addEventListener("peer:discovery", (evt) => {
|
||||
setDiscoveredPeers((prev) => [...prev, evt.detail])
|
||||
addToEventLog(`Discovered peer ${evt.detail.id.toString()}`)
|
||||
});
|
||||
|
||||
helia.libp2p.addEventListener("peer:connect", (evt) => {
|
||||
addToEventLog(`Connected to ${evt.detail.toString()}`)
|
||||
setConnectedPeers((prev) => [...prev, evt.detail])
|
||||
setMultiaddrs(helia.libp2p.getMultiaddrs())
|
||||
});
|
||||
|
||||
helia.libp2p.addEventListener("peer:disconnect", (evt) => {
|
||||
addToEventLog(`Disconnected from ${evt.detail.toString()}`)
|
||||
setConnectedPeers((prev) => prev.filter((peer) => peer.toString() !== evt.detail.toString()))
|
||||
setMultiaddrs(helia.libp2p.getMultiaddrs())
|
||||
});
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
}, [helia, fs, updateInfo])
|
||||
|
||||
|
||||
return (
|
||||
<HeliaContext.Provider
|
||||
value={{
|
||||
helia,
|
||||
fs,
|
||||
error,
|
||||
starting,
|
||||
dhtMode,
|
||||
status,
|
||||
nodeId,
|
||||
discoveredPeers,
|
||||
connectedPeers,
|
||||
multiaddrs,
|
||||
events,
|
||||
addToEventLog
|
||||
}}
|
||||
>{children}</HeliaContext.Provider>
|
||||
)
|
||||
}
|
|
@ -4,4 +4,4 @@ export function buildIpfsUrl (urlFragment: string): string {
|
|||
|
||||
export function buildPatronIpfsUrl (cid: string, token: string): string {
|
||||
return `https://gw.futureporn.net/ipfs/${cid}?token=${token}`
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { gossipsub } from '@chainsafe/libp2p-gossipsub'
|
||||
import { noise } from '@chainsafe/libp2p-noise'
|
||||
import { yamux } from '@chainsafe/libp2p-yamux'
|
||||
import { bootstrap } from '@libp2p/bootstrap'
|
||||
import { ipniContentRouting } from '@libp2p/ipni-content-routing'
|
||||
import { type DualKadDHT, kadDHT } from '@libp2p/kad-dht'
|
||||
import { mplex } from '@libp2p/mplex'
|
||||
import { webRTC, webRTCDirect } from '@libp2p/webrtc'
|
||||
import { webSockets } from '@libp2p/websockets'
|
||||
import { webTransport } from '@libp2p/webtransport'
|
||||
// import { webTransport } from './webtransport'
|
||||
import { ipnsSelector } from 'ipns/selector'
|
||||
import { ipnsValidator } from 'ipns/validator'
|
||||
import { autoNATService } from 'libp2p/autonat'
|
||||
import { circuitRelayTransport } from 'libp2p/circuit-relay'
|
||||
import { identifyService } from 'libp2p/identify'
|
||||
import type { PubSub } from '@libp2p/interface-pubsub'
|
||||
import type { Libp2pOptions } from 'libp2p'
|
||||
import { dcutrService } from 'libp2p/dcutr'
|
||||
|
||||
export function libp2pDefaults (): Libp2pOptions<{ dht: DualKadDHT, pubsub: PubSub, identify: unknown, autoNAT: unknown }> {
|
||||
return {
|
||||
addresses: {
|
||||
listen: [
|
||||
'/webrtc'
|
||||
]
|
||||
},
|
||||
transports: [
|
||||
webRTC(),
|
||||
webRTCDirect(),
|
||||
webTransport(),
|
||||
webSockets(),
|
||||
circuitRelayTransport({
|
||||
discoverRelays: 1
|
||||
})
|
||||
],
|
||||
connectionEncryption: [
|
||||
noise()
|
||||
],
|
||||
streamMuxers: [
|
||||
yamux(),
|
||||
mplex()
|
||||
],
|
||||
peerDiscovery: [
|
||||
bootstrap({
|
||||
list: [
|
||||
'/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN',
|
||||
'/dnsaddr/bootstrap.libp2p.io/p2p/QmQCU2EcMqAqQPR2i9bChDtGNJchTbq5TbXJJ16u19uLTa',
|
||||
'/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb',
|
||||
'/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt',
|
||||
'/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ'
|
||||
]
|
||||
})
|
||||
],
|
||||
contentRouters: [
|
||||
ipniContentRouting('https://cid.contact')
|
||||
],
|
||||
services: {
|
||||
// @ts-expect-error - types are borked...
|
||||
dcutr: dcutrService(),
|
||||
identify: identifyService(),
|
||||
autoNAT: autoNATService(),
|
||||
// pubsub: gossipsub(),
|
||||
dht: kadDHT({
|
||||
pingTimeout: 2000,
|
||||
pingConcurrency: 3,
|
||||
kBucketSize: 20,
|
||||
clientMode: true,
|
||||
validators: {
|
||||
ipns: ipnsValidator
|
||||
},
|
||||
selectors: {
|
||||
ipns: ipnsSelector
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { strapiUrl } from './constants'
|
||||
import { strapiUrl, patreonVideoAccessBenefitId } from './constants'
|
||||
import { IAuthData } from '@/components/auth';
|
||||
|
||||
export interface IPatron {
|
||||
username: string;
|
||||
|
@ -41,6 +42,13 @@ export interface IGoalSample {
|
|||
unfundedGoals: IGoal[];
|
||||
}
|
||||
|
||||
|
||||
export function isEntitledToPatronVideoAccess(authData: IAuthData): boolean {
|
||||
if (!authData.user?.patreonBenefits) return false;
|
||||
const patreonBenefits = authData.user.patreonBenefits
|
||||
return (patreonBenefits.includes(patreonVideoAccessBenefitId))
|
||||
}
|
||||
|
||||
export function unmarshallGoal (data: IMarshalledGoal, pledgeSum: number): IGoal {
|
||||
const amountCents: number = data.attributes.amountCents
|
||||
const goal: IGoal = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { authorName, authorEmail, siteUrl, title, description, siteImage, favicon, authorLink } from './constants'
|
||||
import { Feed } from "feed";
|
||||
import { getVods, getUrl } from '../lib/vods'
|
||||
import { getVods, getUrl } from '@/lib/vods'
|
||||
import { getDateFromSafeDate } from './dates';
|
||||
import { ITagVodRelation } from './tags';
|
||||
|
||||
|
@ -35,8 +35,8 @@ export async function generateFeeds() {
|
|||
title: vod.title || vod.announceTitle,
|
||||
description: vod.title, // @todo vod.spoiler or vod.note could go here
|
||||
content: vod.tagVodRelations.map((tvr: ITagVodRelation) => tvr.tag.name).join(' '),
|
||||
link: getUrl(vod),
|
||||
date: new Date(vod.date),
|
||||
link: getUrl(vod, vod.vtuber.slug, vod.date2),
|
||||
date: new Date(vod.date2),
|
||||
image: vod.vtuber.image
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
import qs from 'qs';
|
||||
import { strapiUrl } from './constants'
|
||||
import { unmarshallTag, ITag } from './tags';
|
||||
import { IVod } from './vods';
|
||||
|
||||
export interface ITagVodRelation {
|
||||
id: number;
|
||||
tag: ITag;
|
||||
vod: IVod;
|
||||
}
|
||||
|
||||
|
||||
export interface ITagVodRelations {
|
||||
data: ITagVodRelation[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
total: number;
|
||||
}
|
||||
}
|
||||
|
||||
export function unmarshallTagVodRelation(d: any): ITagVodRelation {
|
||||
console.log(`unmarshalling tvr`)
|
||||
console.log(d.attributes.tag.data)
|
||||
// console.log(d.attributes.tag.data.attributes.toy.data.attributes.linkTag)
|
||||
return {
|
||||
id: d.id,
|
||||
tag: unmarshallTag(d.attributes.tag.data),
|
||||
vod: d.attributes.vod
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTagVodRelationsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25): Promise<ITagVodRelations> {
|
||||
// get the tag-vod-relations where the vtuber is the vtuber we are interested in.
|
||||
const query = qs.stringify(
|
||||
{
|
||||
populate: {
|
||||
tag: {
|
||||
fields: ['id', 'name'],
|
||||
populate: {
|
||||
toy: {
|
||||
fields: ['linkTag', 'make', 'model', 'image2'],
|
||||
populate: {
|
||||
linkTag: {
|
||||
fields: ['name']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
vod: {
|
||||
populate: {
|
||||
vtuber: {
|
||||
fields: ['slug']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
vod: {
|
||||
vtuber: {
|
||||
id: {
|
||||
$eq: vtuberId
|
||||
}
|
||||
}
|
||||
},
|
||||
tag: {
|
||||
toy: {
|
||||
linkTag: {
|
||||
name: {
|
||||
$notNull: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
pagination: {
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
},
|
||||
sort: {
|
||||
id: 'desc'
|
||||
}
|
||||
}
|
||||
)
|
||||
// we need to return an IToys object
|
||||
// to get an IToys object, we have to get a list of toys from tvrs.
|
||||
|
||||
|
||||
return fetch(`${strapiUrl}/api/tag-vod-relations?${query}`)
|
||||
.then((res) => res.json())
|
||||
.then((j) => {
|
||||
const tvrs = {
|
||||
data: j.data.map(unmarshallTagVodRelation),
|
||||
pagination: j.meta.pagination
|
||||
}
|
||||
return tvrs
|
||||
})
|
||||
}
|
||||
|
|
@ -2,30 +2,19 @@ import { strapiUrl } from './constants'
|
|||
import { fetchPaginatedData } from './fetchers';
|
||||
import { IVod } from './vods';
|
||||
import slugify from 'slugify';
|
||||
import { unmarshallToy, IToy } from './toys';
|
||||
|
||||
export function getTagHref(name: string): string {
|
||||
return `/tags/${slugify(name)}`
|
||||
}
|
||||
|
||||
export interface ITagVodRelation {
|
||||
id: number;
|
||||
tag: ITag;
|
||||
vod: IVod;
|
||||
}
|
||||
|
||||
export interface ITag {
|
||||
id: number;
|
||||
name: string;
|
||||
toy: IToy;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function unmarshallTagVodRelation(d: any): ITagVodRelation {
|
||||
return {
|
||||
id: d.id,
|
||||
tag: unmarshallTag(d.attributes.tag.data),
|
||||
vod: d.attributes.vod
|
||||
}
|
||||
}
|
||||
|
||||
export function unmarshallTag(d: any): ITag {
|
||||
console.log('unmarshalling tag')
|
||||
|
@ -33,6 +22,7 @@ export function unmarshallTag(d: any): ITag {
|
|||
return {
|
||||
id: d.id,
|
||||
name: d.attributes.name,
|
||||
toy: (d?.attributes?.toy?.data) ? unmarshallToy(d.attributes.toy.data) : null,
|
||||
count: 0 // count gets updated later
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
|
||||
|
||||
import qs from 'qs';
|
||||
import { strapiUrl } from './constants'
|
||||
|
||||
|
||||
export interface ITimestamp {
|
||||
id: number;
|
||||
time: number;
|
||||
tagName: string;
|
||||
tnShort: string;
|
||||
tagId: number;
|
||||
vodId: number;
|
||||
}
|
||||
|
||||
export interface ITimestamps {
|
||||
data: ITimestamp[];
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
total: number;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateString (str: string, maxLength: number) {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, maxLength - 1) + '…';
|
||||
}
|
||||
|
||||
export function unmarshallTimestamps (ts: any) {
|
||||
|
||||
if (!ts?.attributes?.tag?.data) throw new Error(`cannot format ts id ${ts.id} because ts.attributes.tag.data is undefined`)
|
||||
const id = ts.id
|
||||
const time = ts.attributes.time
|
||||
const tagName = ts.attributes.tag.data.attributes.name
|
||||
const tnShort = truncateString(tagName, 14)
|
||||
const tagId = ts.attributes.tag.data.id
|
||||
const vodId = ts.attributes.vod.data.id
|
||||
return { id, time, vodId, tagName, tagId, createdAt, creatorId, tnShort, downvotes, upvotes }
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function getTimestampsForVod (vodId: number, page: number = 1, pageSize: number = 25): Promise<ITimestamps> {
|
||||
const query = qs.stringify(
|
||||
{
|
||||
filters: {
|
||||
vod: {
|
||||
id: {
|
||||
'$eq': vodId
|
||||
}
|
||||
}
|
||||
},
|
||||
populate: '*',
|
||||
sort: 'time:asc',
|
||||
pagination: {
|
||||
page: page
|
||||
}
|
||||
}
|
||||
)
|
||||
return fetch(`${strapiUrl}/api/timestamps?${query}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data.data.map(unmarshallTimestamps))
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import qs from 'qs'
|
|||
import { ITagVodRelation, unmarshallTagVodRelation } from './tags'
|
||||
import { IMuxAsset } from './types'
|
||||
import { ITag, unmarshallTag } from '@/lib/tags'
|
||||
import { getTagVodRelationsForVtuber } from './tag-vod-relations'
|
||||
import { PageNotFoundError } from 'next/dist/shared/lib/utils'
|
||||
|
||||
|
||||
export interface IToys {
|
||||
data: IToy[];
|
||||
|
@ -24,15 +27,11 @@ export interface IToy {
|
|||
make: string;
|
||||
model: string;
|
||||
aspectRatio: string;
|
||||
image: string;
|
||||
image2: string;
|
||||
}
|
||||
|
||||
export interface ILinkTag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ToysListProps {
|
||||
interface IToysListProps {
|
||||
toys: IToys;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
|
@ -44,7 +43,17 @@ interface ToysListProps {
|
|||
// return `${siteUrl}/toy/${toy.name}`
|
||||
// }
|
||||
|
||||
export function unarshallLinkTag(d: any): ILinkTag {
|
||||
// export function getToysForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25): Promise<IToys> {
|
||||
// const tvrs = await getTagVodRelationsForVtuber(vtuberId, page, pageNumber);
|
||||
// return {
|
||||
// data: tvrs.data.
|
||||
// pagination: tvrs.pagination
|
||||
// }
|
||||
// }
|
||||
|
||||
export function unarshallLinkTag(d: any): ITag {
|
||||
console.log('unmarshalling linkTag')
|
||||
console.log(d)
|
||||
return {
|
||||
id: d.id,
|
||||
name: d.attributes?.name
|
||||
|
@ -53,52 +62,19 @@ export function unarshallLinkTag(d: any): ILinkTag {
|
|||
|
||||
export function unmarshallToy(d: any): IToy {
|
||||
console.log('unmarshalling toy')
|
||||
// console.log(d)
|
||||
// console.log(d.attributes.linkTag)
|
||||
console.log(d)
|
||||
const toy = {
|
||||
id: d.id,
|
||||
// tags: d.attributes.tags.data.map(unmarshallTag),
|
||||
// linkTag: unmarshallTag(d.attributes.linkTag)
|
||||
tags: [],
|
||||
linkTag: unarshallLinkTag(d.attributes?.linkTag?.data),
|
||||
make: d.attributes.make,
|
||||
model: d.attributes.model,
|
||||
aspectRatio: d.attributes.aspectRatio,
|
||||
image: d.attributes.image2,
|
||||
image2: d.attributes.image2,
|
||||
linkTag: unarshallLinkTag(d.attributes?.linkTag?.data),
|
||||
}
|
||||
console.log(toy)
|
||||
return toy
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function getToysForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25): Promise<IToys> {
|
||||
const query = qs.stringify(
|
||||
{
|
||||
populate: '*',
|
||||
filters: {
|
||||
vtuber: {
|
||||
id: {
|
||||
$eq: vtuberId
|
||||
}
|
||||
}
|
||||
},
|
||||
pagination: {
|
||||
page: page,
|
||||
pageSize: pageSize
|
||||
},
|
||||
sort: {
|
||||
id: 'desc'
|
||||
}
|
||||
}
|
||||
)
|
||||
return fetch(`${strapiUrl}/api/toys?${query}`)
|
||||
.then((res) => res.json())
|
||||
.then((j) => (
|
||||
{
|
||||
data: j.data.map(unmarshallToy),
|
||||
pagination: j.meta.pagination
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
@ -2,11 +2,6 @@
|
|||
|
||||
|
||||
|
||||
export type Contributor = {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
@ -17,10 +12,3 @@ export interface IMuxAsset {
|
|||
assetId: string;
|
||||
}
|
||||
|
||||
|
||||
export interface IB2File {
|
||||
url: string;
|
||||
key: string;
|
||||
uploadId: string;
|
||||
cdnUrl?: string;
|
||||
}
|
|
@ -3,8 +3,9 @@ import { strapiUrl, siteUrl } from './constants'
|
|||
import { getDateFromSafeDate, getSafeDate } from './dates'
|
||||
import { unmarshallVtuber, IVtuber } from './vtubers'
|
||||
import qs from 'qs'
|
||||
import { ITagVodRelation, unmarshallTagVodRelation } from './tags'
|
||||
import { ITagVodRelation, unmarshallTagVodRelation } from './tag-vod-relations'
|
||||
import { IMuxAsset } from './types'
|
||||
import { IB2File, unmarshallB2File } from '@/lib/b2File'
|
||||
|
||||
|
||||
export interface IVods {
|
||||
|
@ -21,17 +22,20 @@ export interface IVod {
|
|||
id: number;
|
||||
title?: string;
|
||||
date: string;
|
||||
date2: string;
|
||||
muxAsset: IMuxAsset;
|
||||
thumbnail: string;
|
||||
vtuber: IVtuber;
|
||||
tagVodRelations: ITagVodRelation;
|
||||
video240Hash: string;
|
||||
videoSrcHash: string;
|
||||
videoSrcB2: IB2File;
|
||||
announceTitle: string;
|
||||
announceUrl: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
|
||||
export function getUrl(vod: IVod, slug: string, date: string): string {
|
||||
return `${siteUrl}/vt/${slug}/vod/${getSafeDate(date)}`
|
||||
}
|
||||
|
@ -53,12 +57,13 @@ export function unmarshallVod(d: any): IVod {
|
|||
console.log('unmarshalling vod')
|
||||
console.log(d)
|
||||
if (!d.attributes?.vtuber?.data) {
|
||||
throw new Error('panick! vod data doesnt contain vtuber. please populate.')
|
||||
throw new Error("panick! vod data doesn't contain vtuber. please populate.")
|
||||
}
|
||||
const vod = {
|
||||
id: d.id,
|
||||
title: d.attributes.title,
|
||||
date: d.attributes.date,
|
||||
date2: d.attributes.date2,
|
||||
muxAsset: {
|
||||
playbackId: d.attributes?.muxAsset?.data?.attributes?.playbackId,
|
||||
assetId: d.attributes?.muxAsset?.data?.attributes?.assetId,
|
||||
|
@ -68,6 +73,7 @@ export function unmarshallVod(d: any): IVod {
|
|||
tagVodRelations: d.attributes?.tagVodRelations?.data.map(unmarshallTagVodRelation),
|
||||
video240Hash: d.attributes?.video240Hash,
|
||||
videoSrcHash: d.attributes?.videoSrcHash,
|
||||
videoSrcB2: unmarshallB2File(d.attributes?.videoSrcB2?.data),
|
||||
announceTitle: d.attributes.announceTitle,
|
||||
announceUrl: d.attributes.announceUrl,
|
||||
note: d.attributes.note,
|
||||
|
@ -87,7 +93,7 @@ export async function getVodForDate(date: Date): Promise<IVod> {
|
|||
},
|
||||
populate: {
|
||||
vtuber: {
|
||||
fields: ['slug', 'displayName', 'image', 'imageBlur']
|
||||
fields: ['slug', 'displayName', 'image', 'imageBlur', 'themeColor']
|
||||
},
|
||||
muxAsset: {
|
||||
fields: ['playbackId', 'assetId']
|
||||
|
@ -98,6 +104,9 @@ export async function getVodForDate(date: Date): Promise<IVod> {
|
|||
tagVodRelations: {
|
||||
fields: ['tag'],
|
||||
populate: ['tag']
|
||||
},
|
||||
videoSrcB2: {
|
||||
fields: ['url', 'key', 'uploadId', 'cdnUrl']
|
||||
}
|
||||
},
|
||||
// populate: '*'
|
||||
|
@ -141,6 +150,9 @@ export async function getVods(sortDesc = true): Promise<IVod[]> {
|
|||
tagVodRelations: {
|
||||
fields: ['tag'],
|
||||
populate: ['tag']
|
||||
},
|
||||
videoSrcB2: {
|
||||
fields: ['url', 'key', 'uploadId', 'cdnUrl']
|
||||
}
|
||||
},
|
||||
sort: {
|
||||
|
@ -169,6 +181,9 @@ export async function getVodsForVtuber(vtuberId: number, page: number = 1, pageS
|
|||
'image',
|
||||
'imageBlur'
|
||||
]
|
||||
},
|
||||
videoSrcB2: {
|
||||
fields: ['url', 'key', 'uploadId', 'cdnUrl']
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
|
@ -204,6 +219,9 @@ export async function getVodsForTag(tag: string): Promise<IVod[]> {
|
|||
populate: {
|
||||
vtuber: {
|
||||
fields: ['slug', 'displayName', 'image', 'imageBlur']
|
||||
},
|
||||
videoSrcB2: {
|
||||
fields: ['url', 'key', 'uploadId', 'cdnUrl']
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
|
@ -229,8 +247,21 @@ export async function getVodsForTag(tag: string): Promise<IVod[]> {
|
|||
* @see https://git.futureporn.net/futureporn/futureporn-historian/issues/1
|
||||
*/
|
||||
export async function getProgress(vtuberSlug: string): Promise<{ complete: number; total: number }> {
|
||||
const data = await fetch(`${strapiUrl}/api/vods?publicationState=live&filters[vtuber][attributes][slug]=${vtuberSlug}`)
|
||||
const query = qs.stringify({
|
||||
filters: {
|
||||
vtuber: {
|
||||
slug: {
|
||||
$eq: vtuberSlug
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const data = await fetch(`${strapiUrl}/api/vods?${query}`)
|
||||
.then((res) => res.json())
|
||||
.then((g) => {
|
||||
console.log(g)
|
||||
return g
|
||||
})
|
||||
|
||||
const total = data.meta.pagination.total
|
||||
|
||||
|
|
11
app/page.tsx
|
@ -1,13 +1,17 @@
|
|||
|
||||
import VTubers from './components/vtubers'
|
||||
import FundingGoal from "../app/components/funding-goal";
|
||||
import { VodsList } from './components/vods-list';
|
||||
import { getVods } from './lib/vods';
|
||||
|
||||
|
||||
export default function Page() {
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
const vods = await getVods(true);
|
||||
// return <>{JSON.stringify(vods, null, 2)}</>
|
||||
return (
|
||||
<>
|
||||
|
||||
<div className="main">
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
|
@ -24,7 +28,8 @@ export default function Page() {
|
|||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<VTubers />
|
||||
{/* <VTubers /> */}
|
||||
<VodsList page={1} pageSize={24} vods={vods} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
@ -55,7 +55,7 @@ const uppy = new Uppy()
|
|||
|
||||
|
||||
|
||||
export default function Page(props) {
|
||||
export default function Page() {
|
||||
// const dashboard = new Dashboard({
|
||||
// inline: true,
|
||||
// target: '#uppy-dashboard',
|
||||
|
@ -64,7 +64,6 @@ export default function Page(props) {
|
|||
// disableInformer: false,
|
||||
// })
|
||||
|
||||
return (<h2 className='title'>Uploads coming soon</h2>)
|
||||
|
||||
useEffect(() => {
|
||||
uppy.setOptions({
|
||||
|
@ -119,16 +118,30 @@ export default function Page(props) {
|
|||
// }
|
||||
// })
|
||||
|
||||
// return <Dashboard
|
||||
// uppy={uppy}
|
||||
// plugins={[
|
||||
// 'Dashboard',
|
||||
// 'AwsS3Multipart',
|
||||
// 'RemoteSources'
|
||||
// ]}
|
||||
// restrictions={{maxNumberOfFiles: 1}}
|
||||
// theme={'auto'}
|
||||
// ></Dashboard>;
|
||||
return <article className="message is-primary mt-5">
|
||||
<div className="message-header">
|
||||
Upload Coming Soon
|
||||
<figure className="image is-32x32 is-rounded">
|
||||
<a target="_blank" href="https://twitter.com/cj_clippy">
|
||||
<img className="is-rounded" src="/images/cj_clippy.jpg" alt="CJ_Clippy" />
|
||||
</a>
|
||||
</figure>
|
||||
</div>
|
||||
<div className="message-body has-text-centered">
|
||||
<p>There are too many lewd VTubers out in the wild! I can't possibly archive all their vods by myself, but together we can!</p>
|
||||
<p className='subtitle mt-3'>Vod uploads coming soon.</p>
|
||||
</div>
|
||||
</article>
|
||||
return <Dashboard
|
||||
uppy={uppy}
|
||||
plugins={[
|
||||
'Dashboard',
|
||||
'AwsS3Multipart',
|
||||
'RemoteSources'
|
||||
]}
|
||||
restrictions={{maxNumberOfFiles: 1}}
|
||||
theme={'auto'}
|
||||
></Dashboard>;
|
||||
}
|
||||
|
||||
// export default function upload () {
|
||||
|
|
|
@ -18,15 +18,19 @@ import {
|
|||
import { getVodsForVtuber, getPaginatedUrl } from '@/lib/vods';
|
||||
import Pager from '@/components/pager';
|
||||
import { ToysList } from '@/components/toys';
|
||||
import { getToysForVtuber } from '@/lib/toys';
|
||||
|
||||
import { IToys, getToysForVtuber } from '@/lib/toys';
|
||||
import { ITagVodRelations, getTagVodRelationsForVtuber } from '@/lib/tag-vod-relations';
|
||||
|
||||
|
||||
export default async function Page({ params }: { params: { slug: string }}) {
|
||||
const toySampleCount = 15
|
||||
const vtuber = await getVtuberBySlug(params.slug)
|
||||
const vods = await getVodsForVtuber(vtuber.id, 1, 9)
|
||||
const toys = await getToysForVtuber(vtuber.id, 1, toySampleCount)
|
||||
const tvrs: ITagVodRelations = await getTagVodRelationsForVtuber(vtuber.id, 1, toySampleCount)
|
||||
const toys: IToys = {
|
||||
data: tvrs.data.map((tvr) => tvr.tag.toy),
|
||||
pagination: tvrs.pagination
|
||||
}
|
||||
|
||||
// Handle loading and error states
|
||||
if (!vtuber) {
|
||||
|
@ -195,20 +199,21 @@ export default async function Page({ params }: { params: { slug: string }}) {
|
|||
</div>
|
||||
|
||||
|
||||
<h2 id="toys" className="title is-3">
|
||||
{/* <h2 id="toys" className="title is-3">
|
||||
<Link href="#toys">Toys</Link>
|
||||
</h2>
|
||||
|
||||
|
||||
<ToysList vtuber={vtuber} toys={toys} page={1} pageSize={toySampleCount} />
|
||||
{(toys.pagination.total > toySampleCount) && <Link href={`/vt/${vtuber.slug}/toys`} className='button mb-5'>See all of {vtuber.displayName}'s toys</Link>}
|
||||
<>
|
||||
<ToysList vtuber={vtuber} toys={toys} page={1} pageSize={toySampleCount} />
|
||||
{(toys.pagination.total > toySampleCount) && <Link href={`/vt/${vtuber.slug}/toys/1`} className='button mb-5'>See all of {vtuber.displayName}'s toys</Link>}
|
||||
</> */}
|
||||
|
||||
<h2 id="vods" className="title is-3">
|
||||
<Link href="#vods">Vods</Link>
|
||||
</h2>
|
||||
|
||||
<VodsList vtuber={vtuber} vods={vods} page={1} pageSize={9} />
|
||||
<Link className='button' href={`/vt/${vtuber.slug}/vods`}>See all {vtuber.displayName} vods</Link>
|
||||
<VodsList vtuber={vtuber} vods={vods.data} page={1} pageSize={9} />
|
||||
<Link className='button' href={`/vt/${vtuber.slug}/vods/1`}>See all {vtuber.displayName} vods</Link>
|
||||
{/* <Pager getPagePath={getPaginatedUrl} slug={vtuber.slug} page={parseInt(1, 10)} pageSize={vods.pagination.pageSize} /> */}
|
||||
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
import { VodsList, VodsListHeading } from '@/components/vods-list'
|
||||
import { getVtuberBySlug } from '@/lib/vtubers'
|
||||
import { IToys, getToysForVtuber } from '@/lib/toys'
|
||||
import { ToysList, ToysListHeading } from '@/components/toys'
|
||||
import Pager from '@/components/pager'
|
||||
|
||||
interface IPageParams {
|
||||
params: {
|
||||
name: string;
|
||||
page: number;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page({ params }: IPageParams) {
|
||||
const vtuber = await getVtuberBySlug(params.slug)
|
||||
const toys: IToys = await getToysForVtuber(vtuber.id, params.page, 24)
|
||||
return (
|
||||
<div className='box'>
|
||||
<div className="">
|
||||
<ToysListHeading slug={vtuber.slug} displayName={vtuber.displayName} />
|
||||
<ToysList toys={toys} pageSize={12}></ToysList>
|
||||
<Pager
|
||||
collection='toys'
|
||||
slug={vtuber.slug}
|
||||
page={parseInt(params.page, 10)}
|
||||
pageCount={toys.pagination.pageCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -8,13 +8,12 @@ import Pager from '@/components/pager'
|
|||
interface IPageParams {
|
||||
params: {
|
||||
name: string;
|
||||
page: number;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Page({ params }: IPageParams) {
|
||||
const vtuber = await getVtuberBySlug(params.slug)
|
||||
const toys: IToys = await getToysForVtuber(vtuber.id, params.page, 24)
|
||||
const toys: IToys = await getToysForVtuber(vtuber.id, 1, 24)
|
||||
return (
|
||||
<div className='box'>
|
||||
<div className="">
|
||||
|
@ -22,9 +21,10 @@ export default async function Page({ params }: IPageParams) {
|
|||
{/* <VodsList vtuber={vtuber} vods={vods} page={params.page} pageSize={24} /> */}
|
||||
<ToysList toys={toys} pageSize={12}></ToysList>
|
||||
<Pager
|
||||
collection='toys'
|
||||
slug={vtuber.slug}
|
||||
page={parseInt(params.page, 10)}
|
||||
pageCount={vods.pagination.pageCount}
|
||||
pageCount={toys.pagination.pageCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
import { VodsList, VodsListHeading } from '@/components/vods-list'
|
||||
import { getVtuberBySlug, getUrl } from '@/lib/vtubers'
|
||||
import { IVods, getVodsForVtuber } from '@/lib/vods'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Pager from '@/components/pager'
|
||||
import { siteUrl } from '@/lib/constants'
|
||||
|
||||
interface IPageParams {
|
||||
params: {
|
||||
|
@ -14,12 +16,11 @@ interface IPageParams {
|
|||
export default async function Page({ params }: IPageParams) {
|
||||
const vtuber = await getVtuberBySlug(params.slug)
|
||||
const vods: IVods = await getVodsForVtuber(vtuber.id, params.page, 24, true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<VodsListHeading slug={vtuber.slug} displayName={vtuber.displayName} />
|
||||
<VodsList vtuber={vtuber} vods={vods} page={params.page} pageSize={24} />
|
||||
<Pager slug={vtuber.slug} page={parseInt(params.page, 10)} pageCount={vods.pagination.pageCount} getPagePath={getUrl} />
|
||||
<VodsList vtuber={vtuber} vods={vods.data} page={params.page} pageSize={24} />
|
||||
<Pager collection='vods' slug={vtuber.slug} page={parseInt(params.page, 10)} pageCount={vods.pagination.pageCount} getPagePath={getUrl} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -17,7 +17,7 @@ export default async function Page({ params }: IPageParams) {
|
|||
return (
|
||||
<>
|
||||
<VodsListHeading slug={vtuber.slug} displayName={vtuber.displayName}></VodsListHeading>
|
||||
<VodsList vtuber={vtuber} vods={vods} page={1} pageSize={24} />
|
||||
<VodsList vtuber={vtuber} vods={vods.data} page={1} pageSize={24} />
|
||||
<Pager collection="vods" slug={vtuber.slug} page={parseInt(1, 10)} pageCount={vods.pagination.pageCount} getPagePath={getPaginatedUrl} />
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -14,3 +14,7 @@ svg.icon g path {
|
|||
fill: rgb(208, 209, 205) !important;
|
||||
}
|
||||
|
||||
svg.bigIcon {
|
||||
width: 10em;
|
||||
height: 10em;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 64 80" style="enable-background:new 0 0 64 64;" xml:space="preserve"><g><path d="M17,53C8.7,53,2,46.3,2,38c0-3.2,1-6.2,2.8-8.7l-1.6-1.2C1.1,31,0,34.4,0,38c0,5.8,2.9,10.8,7.3,13.9C9.9,59.3,10,64,10,64 l2,0c0-0.2,0-4.1-2.1-10.6c2.2,1,4.6,1.6,7.1,1.6c4.4,0,8.5-1.7,11.7-4.7l-1.4-1.5C24.5,51.5,20.9,53,17,53z"/><path d="M64,38c0-3.6-1.1-7-3.2-9.9l-1.6,1.2C61,31.8,62,34.8,62,38c0,8.3-6.7,15-15,15c-3.9,0-7.5-1.5-10.3-4.1l-1.4,1.5 c3.2,3,7.3,4.7,11.7,4.7c2.5,0,4.9-0.6,7.1-1.6C52,59.9,52,63.8,52,64l2,0c0,0,0.1-4.7,2.7-12.1C61.1,48.8,64,43.8,64,38z"/><path d="M8,18h19v-2h-2.1c-0.1-0.8-0.3-2-0.8-3.6c2,0.9,4.6,1.6,7.9,1.6c3.3,0,5.9-0.6,7.9-1.6c-0.5,1.5-0.7,2.8-0.8,3.6H29v2h27 v20h2V16H41.1c0.2-1.1,0.6-3.1,1.8-5.5C46.5,7.4,47,3.2,47,3.1l-2-0.2c0,0.4-1,9.1-13,9.1c-11.9,0-13-8.7-13-9.1l-2,0.2 c0,0.1,0.5,4.3,4.1,7.4c1.2,2.4,1.6,4.3,1.8,5.5H6v22h2V18z"/><path d="M8,41c0,2.4,1.6,4,4,4c1.9,0,3.4-1.3,3.9-3h32.3c0.4,1.7,2,3,3.9,3c2.4,0,4-1.6,4-4v-1H8V41z M53.8,42 c-0.4,0.9-1.3,1-1.8,1c-0.7,0-1.4-0.4-1.7-1H53.8z M13.7,42c-0.3,0.6-1,1-1.7,1c-0.5,0-1.4-0.1-1.8-1H13.7z"/><path d="M31,6h2c1.7,0,3-1.3,3-3s-1.3-3-3-3h-2c-1.7,0-3,1.3-3,3S29.3,6,31,6z M31,2h2c0.6,0,1,0.4,1,1s-0.4,1-1,1h-2 c-0.6,0-1-0.4-1-1S30.4,2,31,2z"/><polygon points="17.7,33.7 21,30.4 24.3,33.7 25.7,32.3 22.4,29 25.7,25.7 24.3,24.3 21,27.6 17.7,24.3 16.3,25.7 19.6,29 16.3,32.3 "/><polygon points="28.7,33.7 32,30.4 35.3,33.7 36.7,32.3 33.4,29 36.7,25.7 35.3,24.3 32,27.6 28.7,24.3 27.3,25.7 30.6,29 27.3,32.3 "/><polygon points="39.7,33.7 43,30.4 46.3,33.7 47.7,32.3 44.4,29 47.7,25.7 46.3,24.3 43,27.6 39.7,24.3 38.3,25.7 41.6,29 38.3,32.3 "/></g><text x="0" y="79" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Anatolii Babii</text><text x="0" y="84" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
|
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 12 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 640" x="0px" y="0px"><title>love charger copy 2</title><g data-name="Layer 16"><path d="M297.672,362.168H214.048a5,5,0,0,0-5,5c0,14.509,20.125,25.452,46.812,25.452s46.812-10.943,46.812-25.452A5,5,0,0,0,297.672,362.168ZM255.86,382.62c-16.282,0-29.383-4.872-34.5-10.452h68.993C285.243,377.748,272.141,382.62,255.86,382.62Z"/><path d="M255.864,31.252C153.049,31.252,69.4,114.9,69.4,217.712V420.078a5,5,0,0,0,2.8,4.49l24.42,11.958a5,5,0,0,0,7.2-4.49v-6.855l6.125,9.553a5,5,0,0,0,4.209,2.3h13.138a5,5,0,0,0,5-5V422.8h46.477a146.329,146.329,0,0,0,154.2,0h46.467v9.237a5,5,0,0,0,5,5H397.57a5,5,0,0,0,4.209-2.3l6.125-9.553v6.855a5,5,0,0,0,7.2,4.49l24.42-11.958a5,5,0,0,0,2.8-4.49V217.712C442.324,114.9,358.678,31.252,255.864,31.252ZM132.294,412.8V377.021A147.88,147.88,0,0,0,164.566,412.8Zm247.139,0h-32.28a147.884,147.884,0,0,0,32.28-35.776Zm0-56.375A136.854,136.854,0,0,1,255.86,434.757h0a136.858,136.858,0,0,1-123.566-78.305V278.218a4.393,4.393,0,0,0,7.066-3.506,41.433,41.433,0,0,1,13.488-30.6h4.022a42.2,42.2,0,0,0-12.414,30.3c0,22.223,16.292,40.3,36.319,40.3s36.318-18.081,36.318-40.3a42.206,42.206,0,0,0-12.413-30.3h4.294a41.424,41.424,0,0,1,13.493,30.6,4.413,4.413,0,0,0,8.826,0,50.074,50.074,0,0,0-10.41-30.6h34.228a5,5,0,0,0,4.9-4.009l4.23-20.916,3.668,20.793a5,5,0,0,0,4.924,4.132h60.341a5,5,0,0,0,4.9-4.009l4.23-20.916,3.316,18.795-52.814,32.987a4.413,4.413,0,0,0,.5,7.753l69.471,31.927a4.413,4.413,0,0,0,3.685-8.02l-62.035-28.51,48.044-30.007h22.611l4.354,7.876ZM132.294,261.539V257.6l7.458-13.489h1.2A50.242,50.242,0,0,0,132.294,261.539Zm56.484-27.624.22,1.252a32.8,32.8,0,0,0-5.856-.96l.2-.972A41.424,41.424,0,0,1,188.778,233.915Zm-3.679-9.386,1.08-5.343.978,5.543C186.477,224.643,185.787,224.587,185.1,224.529Zm21.994,49.888c0,16.71-11.806,30.3-26.318,30.3s-26.319-13.594-26.319-30.3,11.806-30.3,26.319-30.3S207.093,257.707,207.093,274.417ZM432.324,416.959,417.9,424.02v-15.9a5,5,0,0,0-9.209-2.7l-13.858,21.615h-5.4V250.7a5,5,0,0,0-.624-2.419L382.4,236.693a5,5,0,0,0-4.376-2.581H368.01a4.4,4.4,0,0,0-5.732-6.533l-7.51,4.69-7.2-40.834a5,5,0,0,0-9.825-.122l-8.655,42.8H277.029L269.5,191.436a5,5,0,0,0-9.825-.123l-8.655,42.8H198.967l-7.529-42.677a5,5,0,0,0-9.825-.122l-8.655,42.8H136.8a5,5,0,0,0-4.375,2.581l-9.51,17.2a4.989,4.989,0,0,0-.624,2.419V427.036h-5.4l-13.859-21.615a5,5,0,0,0-9.209,2.7v15.9L79.4,416.959V217.712c0-97.3,79.161-176.46,176.462-176.46s176.46,79.16,176.46,176.46Z"/></g><text x="0" y="527" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by KEN111</text><text x="0" y="532" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
|
After Width: | Height: | Size: 2.7 KiB |
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 100 125" style="enable-background:new 0 0 100 100;" xml:space="preserve"><style type="text/css">
|
||||
.st0{fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
.st1{fill:none;stroke:#000000;stroke-width:3;stroke-linecap:round;stroke-miterlimit:10;}
|
||||
</style><g><path class="st0" d="M85,42.9"/><path class="st0" d="M66.8,17.9C66.2,12,70.5,6.7,76.4,6.1s11.1,3.7,11.7,9.6c0.6,5.9-3.7,11.1-9.6,11.7"/><path class="st0" d="M22.4,27.3c-5.8-1.1-9.6-6.7-8.5-12.5s6.7-9.6,12.5-8.5s9.6,6.7,8.5,12.5"/></g><g><path class="st1" d="M77.1,65.6c-5.2,8.8-15.4,14.5-26.7,13.9c-9.1-0.5-17-4.9-21.9-11.4"/><path class="st1" d="M39.2,63.9L39.2,63.9c-2.3,0-4.2-1.9-4.2-4.2v-6c0-2.3,1.9-4.2,4.2-4.2h0c2.3,0,4.2,1.9,4.2,4.2v6"/><path class="st1" d="M64.5,63.3L64.5,63.3c2.3,0,4.2-1.9,4.2-4.2v-6c0-2.3-1.9-4.2-4.2-4.2h0c-2.3,0-4.2,1.9-4.2,4.2v6"/><line class="st1" x1="48.2" y1="70" x2="51.9" y2="66.3"/><line class="st1" x1="55.5" y1="70" x2="51.9" y2="66.3"/><circle class="st1" cx="39.2" cy="53.7" r="3.6"/><circle class="st1" cx="64.5" cy="52.5" r="3.6"/></g><g><path class="st0" d="M39.9,29.5c0.9,8.1,7,14.6,15.2,15.3c-1.7-3.1-0.6-7,1.3-9.7c0.7,2,2,3.8,3.5,5.1c1.6,1.4,3.4,2.3,5.5,2.8 c1.2,0.3,3.1,0,4,0.7c0.6,0.5,1.1,1.6,1.6,2.3c0.9,1.3,1.6,2.7,2.3,4.2c1.9,4,3.2,8.3,3.7,12.7c0.7,6.7-0.8,13.5-4.1,19.4 c8-4,11.4-14.2,12-22.4c0.4-6-0.4-12-1.8-17.9c3.8,2.5,10,1.1,14,0c-3.4-0.7-6.7-2.1-9.6-3.8c-2.9-1.7-5.5-3.8-7.7-6.3 c-1.2-1.4-2.3-2.9-3.2-4.4c-0.4-0.8-0.7-1.6-1.3-2.4c-1.9-2.7-5.1-4.9-8-6.4c-6-3.3-12.9-4.2-19.6-3c-2,0.4-4,1-6,1.4 c-1.3,0.3-2.6,0.6-4,1c-4.5,1.4-8.9,3.5-12.5,6.7c-2.2,1.9-4.1,4.2-5.4,6.8c-0.7,1.4-1.3,2.8-1.8,4.3c-0.5,1.5-0.5,3.2-1,4.7 c-0.6,1.7-2.1,3.4-3.2,4.8c-2.6,2.9-5.7,5-9.4,6.3c-0.8,0.3-2.8,0.9-1.3,1.7C4.7,54.2,7.3,54,9,53.9c3.8-0.1,11.5-0.6,13.2-5 c-4,12.2-2.2,27.2,7.6,36.1c-2.2-13.1-1.7-27.5,2.1-40.2c0.7-2.3,1.2-4.6,1.7-7c1,3,3.6,5.5,6.3,6.9c-0.3-2.9-0.7-5.8-1-8.7 c-0.2-1.3-0.3-2.2,0.1-3.5C39.3,31.5,39.6,30.5,39.9,29.5z"/></g><text x="0" y="115" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Zackary Cloe</text><text x="0" y="120" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
|
After Width: | Height: | Size: 2.4 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" version="1.1" x="0px" y="0px" viewBox="0 0 100 125"><g transform="translate(0,-952.36218)"><path style="text-indent:0;text-transform:none;direction:ltr;block-progression:tb;baseline-shift:baseline;color:#000000;enable-background:accumulate;" d="m 43,1046.3623 c 5.4992,0 10,-4.5008 10,-10 0,-3.8157 -2.18,-7.1594 -5.3438,-8.8438 L 53.5,1008.3311 c 0.1678,0.01 0.3306,0.031 0.5,0.031 3.667,0 6.9511,-1.6705 9.1562,-4.2812 l 15.8126,12.4687 c -0.6183,1.1332 -0.9688,2.4392 -0.9688,3.8125 0,4.3946 3.6054,8 8,8 4.3946,0 8,-3.6054 8,-8 0,-4.3946 -3.6054,-8 -8,-8 -1.5584,0 -3.0157,0.4688 -4.25,1.25 L 65.2188,1000.5809 C 65.7205,999.26137 66,997.85267 66,996.36207 c 0,-2.5739 -0.8272,-4.9785 -2.2188,-6.9375 L 76.5,976.70587 c 1.5783,1.0436 3.4773,1.6562 5.5,1.6562 5.4991,0 10,-4.5008 10,-10 0,-5.4992 -4.5009,-10 -10,-10 -5.4991,0 -10,4.5008 -10,10 0,2.0371 0.6303,3.9149 1.6875,5.5 l -12.75,12.75 c -1.9668,-1.4096 -4.3453,-2.25 -6.9375,-2.25 -2.2525,0 -4.3509,0.6293 -6.1562,1.7188 l -7.7813,-9.1876 c 1.2119,-1.5337 1.9375,-3.4381 1.9375,-5.5312 0,-4.9469 -4.0531,-9 -9,-9 -4.9469,0 -9,4.0531 -9,9 0,4.9469 4.0531,9 9,9 1.4112,0 2.7451,-0.3512 3.9375,-0.9375 l 7.8437,9.2813 c -1.7315,2.0809 -2.7812,4.7515 -2.7812,7.6562 0,0.6191 0.065,1.2181 0.1562,1.8125 l -23.8124,7.65633 c -1.288,-1.5199 -3.2135,-2.4688 -5.3438,-2.4688 -3.8423,0 -7,3.1577 -7,7 0,3.8423 3.1577,7 7,7 3.8423,0 7,-3.1577 7,-7 0,-0.2843 -0.029,-0.5679 -0.062,-0.8438 l 23.4687,-7.5312 c 1.3361,2.4872 3.5442,4.447 6.1876,5.5 l -5.7813,18.9062 c -0.2656,-0.021 -0.5416,-0.031 -0.8125,-0.031 -5.4992,0 -10,4.5008 -10,10 0,5.4992 4.5008,10 10,10 z m 0,-4 c -3.3374,0 -6,-2.6626 -6,-6 0,-3.3374 2.6626,-6 6,-6 3.3374,0 6,2.6626 6,6 0,3.3374 -2.6626,6 -6,6 z m 43,-18 c -2.2328,0 -4,-1.7672 -4,-4 0,-2.2328 1.7672,-4 4,-4 2.2328,0 4,1.7672 4,4 0,2.2328 -1.7672,4 -4,4 z m -73,-11 c -1.6805,0 -3,-1.3195 -3,-3 0,-1.6805 1.3195,-3 3,-3 1.6805,0 3,1.3195 3,3 0,1.6805 -1.3195,3 -3,3 z m 41,-9 c -4.4419,0 -8,-3.558 -8,-8.00003 0,-4.442 3.5581,-8 8,-8 4.4419,0 8,3.558 8,8 0,4.44203 -3.5581,8.00003 -8,8.00003 z M 33,976.36227 c -2.7851,0 -5,-2.2149 -5,-5 0,-2.7851 2.2149,-5 5,-5 2.7851,0 5,2.2149 5,5 0,2.7851 -2.2149,5 -5,5 z m 49,-2 c -3.3373,0 -6,-2.6626 -6,-6 0,-3.3373 2.6627,-6 6,-6 3.3373,0 6,2.6627 6,6 0,3.3374 -2.6627,6 -6,6 z" fill="#000000" fill-opacity="1" stroke="none" marker="none" visibility="visible" display="inline" overflow="visible"/></g><text x="0" y="115" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">Created by Three Six Five</text><text x="0" y="120" fill="#000000" font-size="5px" font-weight="bold" font-family="'Helvetica Neue', Helvetica, Arial-Unicode, Arial, Sans-serif">from the Noun Project</text></svg>
|
After Width: | Height: | Size: 3.1 KiB |
29
package.json
|
@ -9,34 +9,55 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chainsafe/libp2p-gossipsub": "^10.1.0",
|
||||
"@chainsafe/libp2p-noise": "^13.0.1",
|
||||
"@chainsafe/libp2p-yamux": "^5.0.0",
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@helia/interface": "^2.0.0",
|
||||
"@helia/unixfs": "^1.4.1",
|
||||
"@icons-pack/react-simple-icons": "^8.0.1",
|
||||
"@libp2p/bootstrap": "^9.0.5",
|
||||
"@libp2p/interface-pubsub": "^4.0.1",
|
||||
"@libp2p/ipni-content-routing": "^2.0.0",
|
||||
"@libp2p/kad-dht": "^10.0.5",
|
||||
"@libp2p/mplex": "^9.0.5",
|
||||
"@libp2p/webrtc": "^3.1.9",
|
||||
"@libp2p/websockets": "^7.0.5",
|
||||
"@libp2p/webtransport": "^3.0.9",
|
||||
"@react-hookz/web": "^23.1.0",
|
||||
"@types/react": "^16.14.45",
|
||||
"@uppy/aws-s3-multipart": "^3.5.3",
|
||||
"@uppy/dashboard": "^3.5.1",
|
||||
"@uppy/drag-drop": "^3.0.3",
|
||||
"@uppy/file-input": "^3.0.3",
|
||||
"@uppy/progress-bar": "^3.0.3",
|
||||
"@uppy/react": "^3.1.3",
|
||||
"@vidstack/react": "^0.6.13",
|
||||
"bulma": "^0.9.4",
|
||||
"bulma-prefers-dark": "0.1.0-beta.1",
|
||||
"cid": "github:multiformats/cid",
|
||||
"date-fns-tz": "^2.0.0",
|
||||
"feed": "^4.2.2",
|
||||
"helia": "^2.0.1",
|
||||
"hls.js": "^1.4.12",
|
||||
"ipns": "^6.0.5",
|
||||
"libp2p": "^0.46.9",
|
||||
"next": "^13.4.19",
|
||||
"next-react-svg": "^1.2.0",
|
||||
"nextjs-toploader": "^1.4.2",
|
||||
"nprogress": "^0.2.0",
|
||||
"plyr": "^3.7.8",
|
||||
"plyr-react": "^5.3.0",
|
||||
"qs": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-use-downloader": "^1.2.4",
|
||||
"sass": "^1.66.1",
|
||||
"swr": "^2.2.1",
|
||||
"@types/react": "^16.14.45",
|
||||
"bulma": "^0.9.4"
|
||||
"swr": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^14.18.54",
|
||||
|
|
2458
pnpm-lock.yaml
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 27 KiB |
|
@ -39,4 +39,5 @@
|
|||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"types": ["vidstack/globals"]
|
||||
}
|
||||
|
|