This commit is contained in:
Chris Grimmett 2023-09-15 10:51:00 -08:00
parent 715a78aa19
commit ff7f4f1e8a
63 changed files with 4052 additions and 499 deletions

4
.gitignore vendored
View File

@ -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 ###

View File

@ -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 doesnt store any VODs. When I missed a stream, I felt sad. I felt like I had missed out and theres no way Id ever find out what happened.</p>
<p>Viewers want to find content that interests them. Futureporn enables vod tagging for easy browsing.</p>
<p>Im 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 Melodys 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 didnt 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 couldnt 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>Ive been working on this project off and on for about a year and a half. Its 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 Melodys 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 Melodys livestreams. Now, the project is becoming a sort of a time capsule. Weve all seen how Melody has been de-platformed a half dozen times, and Ive 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. Its 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 VODs replication and servability to future viewers.</p>
<p>But wait, theres 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 Melodys vibrator activity from the video, and export to a data file. This data file could be used to send good vibes to a viewers 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 theres 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">

View File

@ -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)
}
})

View File

@ -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 doesnt store any VODs. When I missed a stream, I felt sad. I felt like I had missed out and theres no way Id ever find out what happened.</p>
<p>Im 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 Melodys Chaturbate streams.</p>
<p>I put the project on hold for a few months, because I didnt think I could make a website that could handle the traffic that the Science Team would generate.</p>
<p>I couldnt shake the idea, though. I wanted Futureporn to exist no matter what!</p>
<p>Ive been working on this project off and on for about a year and a half. Its 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 Melodys 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 Melodys livestreams. Now, the project is becoming a sort of a time capsule. Weve all seen how Melody has been de-platformed a half dozen times, and Ive 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. Its 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 VODs replication and servability to future viewers.</p>
<p>But wait, theres 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 Melodys vibrator activity from the video, and export to a data file. This data file could be used to send good vibes to a viewers 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 theres 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>
)
}

5
app/blog/page.tsx Normal file
View File

@ -0,0 +1,5 @@
export default function Page() {
return (
<p>blog</p>
)
}

View File

@ -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);
}

View File

@ -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}</>
)

View File

@ -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>
</>
);
});

View File

@ -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>

View File

@ -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()

View File

@ -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}`} />
}

75
app/components/ipfs.tsx Normal file
View File

@ -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

View File

@ -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 <></>
}

View File

@ -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 */}

View File

@ -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">

18
app/components/plyr.tsx Normal file
View File

@ -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

39
app/components/plyr2.tsx Normal file
View File

@ -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

144
app/components/tagger.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>

View File

@ -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}
}

View File

@ -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> */}
</>
)
}

View File

@ -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>
</>
)
}

View File

@ -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;

View File

@ -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
}

View File

@ -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)}

View File

@ -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}
/>
</>

View File

@ -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>
)

View File

@ -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>

View File

@ -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)
}

View File

@ -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>

View File

@ -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">

20
app/lib/b2File.ts Normal file
View File

@ -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
}
}

View File

@ -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')

View File

@ -1,4 +1,8 @@
import { IContributor } from "./types";
export type IContributor = {
name: string;
url?: string;
}
export async function getContributors(): Promise<IContributor[]> {
try {

29
app/lib/getHelia.ts Normal file
View File

@ -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;
};

166
app/lib/ipfs-provider.ts Normal file
View File

@ -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>
)
}

View File

@ -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}`
}
}

78
app/lib/libp2p.ts Normal file
View File

@ -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
}
})
}
}
}

View File

@ -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 = {

View File

@ -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
})
})

View File

@ -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
})
}

View File

@ -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
}
}

68
app/lib/timestamps.ts Normal file
View File

@ -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))
}

View File

@ -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
}
))
}

View File

@ -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;
}

View File

@ -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

View File

@ -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>

View File

@ -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 () {

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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} />
</>
)
}

View File

@ -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} />
</>
)

View File

@ -14,3 +14,7 @@ svg.icon g path {
fill: rgb(208, 209, 205) !important;
}
svg.bigIcon {
width: 10em;
height: 10em;
}

View File

@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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",

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@ -39,4 +39,5 @@
"exclude": [
"node_modules"
],
"types": ["vidstack/globals"]
}