This commit is contained in:
Chris Grimmett 2024-01-20 08:16:14 -08:00
commit d60c6ac3bb
464 changed files with 44681 additions and 0 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
LICENSE
.nvmrc
CHECKS
app.json
.env*
compose/
docker-compose.*
.vscode

148
.gitignore vendored Normal file
View File

@ -0,0 +1,148 @@
compose/
.env
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM node:20-slim AS base
ENV NEXT_TELEMETRY_DISABLED 1
RUN corepack enable
FROM base AS build
WORKDIR /usr/src/fp-monorepo
RUN mkdir /usr/src/next
COPY ./pnpm-lock.yaml ./
COPY ./pnpm-workspace.yaml ./
COPY ./packages/next/package.json ./packages/next/
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store pnpm install
COPY . .
RUN pnpm deploy --filter=fp-next /usr/src/next
FROM base AS dev
WORKDIR /app
COPY --from=build /usr/src/next /app
CMD ["pnpm", "run", "dev"]

103
compose.prod.yml Normal file
View File

@ -0,0 +1,103 @@
version: '3.4'
services:
link2cid:
container_name: fp-link2cid
image: insanity54/link2cid:latest
ports:
- "3939:3939"
environment:
API_KEY: ${LINK2CID_API_KEY}
IPFS_URL: "http://ipfs0:5001"
ipfs0:
container_name: fp-ipfs0
image: ipfs/kubo:release
ports:
- "5001:5001"
volumes:
- ./packages/ipfs0:/data/ipfs
cluster0:
container_name: fp-cluster0
image: ipfs/ipfs-cluster:latest
depends_on:
- ipfs0
environment:
CLUSTER_PEERNAME: cluster0
CLUSTER_SECRET: ${CLUSTER_SECRET} # From shell variable if set
CLUSTER_IPFSHTTP_NODEMULTIADDRESS: /dns4/ipfs0/tcp/5001
CLUSTER_CRDT_TRUSTEDPEERS: '*' # Trust all peers in Cluster
CLUSTER_RESTAPI_HTTPLISTENMULTIADDRESS: /ip4/0.0.0.0/tcp/9094 # Expose API
CLUSTER_RESTAPI_BASICAUTHCREDENTIALS: ${CLUSTER_RESTAPI_BASICAUTHCREDENTIALS}
CLUSTER_MONITORPINGINTERVAL: 2s # Speed up peer discovery
ports:
- "127.0.0.1:9094:9094"
volumes:
- ./packages/cluster0:/data/ipfs-cluster
strapi:
container_name: fp-strapi
image: elestio/strapi-development
depends_on:
- db
environment:
ADMIN_PASSWORD: ${STRAPI_ADMIN_PASSWORD}
ADMIN_EMAIL: ${STRAPI_ADMIN_EMAIL}
BASE_URL: ${STRAPI_BASE_URL}
SMTP_HOST: 172.17.0.1
SMTP_PORT: 25
SMTP_AUTH_STRATEGY: NONE
SMTP_FROM_EMAIL: sender@email.com
DATABASE_CLIENT: postgres
DATABASE_PORT: ${DATABASE_PORT}
DATABASE_NAME: ${DATABASE_NAME}
DATABASE_USERNAME: ${DATABASE_USERNAME}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
JWT_SECRET: ${STRAPI_JWT_SECRET}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET}
APP_KEYS: ${STRAPI_APP_KEYS}
NODE_ENV: development
DATABASE_HOST: db
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT}
ports:
- "1337:1337"
volumes:
- ./packages/strapi/config:/opt/app/config
- ./packages/strapi/src:/opt/app/src
# - ./packages/strapi/package.json:/opt/package.json
# - ./packages/strapi/yarn.lock:/opt/yarn.lock
- ./packages/strapi/.env:/opt/app/.env
- ./packages/strapi/public/uploads:/opt/app/public/uploads
# - ./packages/strapi/entrypoint.sh:/opt/app/entrypoint.sh
next:
container_name: fp-next
build:
context: ./packages/next
dockerfile: Dockerfile
environment:
REVALIDATION_TOKEN: ${NEXT_REVALIDATION_TOKEN}
NODE_ENV: production
ports:
- "3000:3000"
volumes:
- ./packages/next/
db:
container_name: fp-db
image: postgres:latest
restart: always
environment:
POSTGRES_DB: ${DATABASE_NAME}
POSTGRES_USER: ${DATABASE_USERNAME}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
PGDATA: /var/lib/postgresql/data
volumes:
- ./packages/db/pgdata:/var/lib/postgresql/data
ports:
- "5433:5432"

190
compose.yml Normal file
View File

@ -0,0 +1,190 @@
version: '3.4'
services:
chisel:
container_name: fp-chisel
image: jpillora/chisel
ports:
- "9312:9312"
restart: on-failure
command: "client --auth=${CHISEL_AUTH} ${CHISEL_SERVER} R:8899:cluster0:9094 R:8901:link2cid:3939 R:8900:strapi:1337 R:8902:next:3000 R:8903:uppy:3020"
link2cid:
container_name: fp-link2cid
restart: on-failure
image: insanity54/link2cid:latest
ports:
- "3939:3939"
environment:
API_KEY: ${LINK2CID_API_KEY}
IPFS_URL: "http://ipfs0:5001"
ipfs0:
container_name: fp-ipfs0
restart: on-failure
image: ipfs/kubo:release
ports:
- "5001:5001"
volumes:
- ./compose/ipfs0:/data/ipfs
cluster0:
container_name: fp-cluster0
image: ipfs/ipfs-cluster:latest
restart: on-failure
depends_on:
- ipfs0
environment:
CLUSTER_PEERNAME: cluster0
CLUSTER_SECRET: ${CLUSTER_SECRET} # From shell variable if set
CLUSTER_IPFSHTTP_NODEMULTIADDRESS: /dns4/ipfs0/tcp/5001
CLUSTER_CRDT_TRUSTEDPEERS: '*' # Trust all peers in Cluster
CLUSTER_RESTAPI_HTTPLISTENMULTIADDRESS: /ip4/0.0.0.0/tcp/9094 # Expose API
CLUSTER_RESTAPI_BASICAUTHCREDENTIALS: ${CLUSTER_RESTAPI_BASICAUTHCREDENTIALS}
CLUSTER_MONITORPINGINTERVAL: 2s # Speed up peer discovery
ports:
- "127.0.0.1:9094:9094"
volumes:
- ./compose/cluster0:/data/ipfs-cluster
strapi:
container_name: fp-strapi
image: fp-strapi:14
build:
context: ./packages/strapi
dockerfile: Dockerfile
restart: on-failure
depends_on:
- db
# env_file: ./packages/strapi/.env
environment:
# ADMIN_PASSWORD: ${STRAPI_ADMIN_PASSWORD}
# ADMIN_EMAIL: ${STRAPI_ADMIN_EMAIL}
BASE_URL: ${STRAPI_BASE_URL}
SMTP_HOST: 172.17.0.1
SMTP_PORT: 25
SMTP_AUTH_STRATEGY: NONE
SMTP_FROM_EMAIL: sender@example.com
SENDGRID_API_KEY: ${SENDGRID_API_KEY}
DATABASE_CLIENT: postgres
DATABASE_HOST: db
DATABASE_PORT: ${POSTGRES_PORT}
DATABASE_NAME: ${POSTGRES_DB}
DATABASE_USERNAME: ${POSTGRES_USER}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${STRAPI_JWT_SECRET}
ADMIN_JWT_SECRET: ${STRAPI_ADMIN_JWT_SECRET}
APP_KEYS: ${STRAPI_APP_KEYS}
NODE_ENV: ${NODE_ENV}
API_TOKEN_SALT: ${STRAPI_API_TOKEN_SALT}
TRANSFER_TOKEN_SALT: ${STRAPI_TRANSFER_TOKEN_SALT}
MUX_SIGNING_KEY_PRIVATE_KEY: ${MUX_SIGNING_KEY_PRIVATE_KEY}
MUX_SIGNING_KEY_ID: ${MUX_SIGNING_KEY_ID}
MUX_PLAYBACK_RESTRICTION_ID: ${MUX_PLAYBACK_RESTRICTION_ID}
STRAPI_URL: ${STRAPI_URL}
CDN_BUCKET_URL: ${CDN_BUCKET_URL}
CDN_BUCKET_USC_URL: ${CDN_BUCKET_USC_URL}
S3_USC_BUCKET_KEY_ID: ${S3_USC_BUCKET_KEY_ID}
S3_USC_BUCKET_APPLICATION_KEY: ${S3_USC_BUCKET_APPLICATION_KEY}
S3_USC_BUCKET_NAME: ${S3_USC_BUCKET_NAME}
S3_USC_BUCKET_ENDPOINT: ${S3_USC_BUCKET_ENDPOINT}
S3_USC_BUCKET_REGION: ${S3_USC_BUCKET_REGION}
AWS_ACCESS_KEY_ID: ${S3_USC_BUCKET_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${S3_USC_BUCKET_APPLICATION_KEY}
ports:
- "1337:1337"
volumes:
- ./packages/strapi/config:/opt/app/config
- ./packages/strapi/src:/opt/app/src
- ./packages/strapi/database:/opt/app/database
- ./packages/strapi/public/uploads:/opt/app/public/uploads
- ./packages/strapi/package.json:/opt/app/package.json
- ./packages/strapi/yarn.lock:/opt/app/yarn.lock
# - ./packages/strapi/.env:/opt/app/.env
# - ./packages/strapi/entrypoint.sh:/opt/app/entrypoint.sh
next:
container_name: fp-next
build:
context: .
dockerfile: Dockerfile
target: dev
restart: on-failure
environment:
REVALIDATION_TOKEN: ${NEXT_REVALIDATION_TOKEN}
NODE_ENV: development
NEXT_PUBLIC_STRAPI_URL: ${NEXT_PUBLIC_STRAPI_URL}
NEXT_PUBLIC_UPPY_COMPANION_URL: ${NEXT_PUBLIC_UPPY_COMPANION_URL}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
ports:
- "3000:3000"
volumes:
# - /app/node_modules
# - /app/.next
# - /app/.pnpm-store
- ./packages/next/app:/app/app
db:
container_name: fp-db
image: postgres:16
restart: on-failure
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
PGDATA: /var/lib/postgresql/data
PGPORT: ${POSTGRES_PORT}
volumes:
- ./compose/db/pgdata:/var/lib/postgresql/data
ports:
- "15432:15432"
pgadmin:
container_name: fp-pgadmin
image: dpage/pgadmin4:8
restart: on-failure
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
PGADMIN_DISABLE_POSTFIX: yessir
GUNICORN_ACCESS_LOGFILE: /tmp/pgadmin-gunicorn-access.log # this makes console output less noisy
ports:
- "5050:80"
uppy:
container_name: fp-uppy
build:
context: .
dockerfile: ./packages/uppy/Dockerfile
target: run
restart: on-failure
environment:
SESSION_SECRET: ${UPPY_SESSION_SECRET}
PORT: ${UPPY_PORT}
FILEPATH: ${UPPY_FILEPATH}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL}
HOST: ${UPPY_HOST}
UPLOAD_URLS: ${UPPY_UPLOAD_URLS}
SECRET: ${UPPY_SECRET}
SERVER_BASE_URL: ${UPPY_SERVER_BASE_URL}
B2_ENDPOINT: ${UPPY_B2_ENDPOINT}
B2_BUCKET: ${UPPY_B2_BUCKET}
B2_SECRET: ${UPPY_B2_SECRET}
B2_KEY: ${UPPY_B2_KEY}
B2_REGION: ${UPPY_B2_REGION}
DRIVE_KEY: ${UPPY_DRIVE_KEY}
DRIVE_SECRET: ${UPPY_DRIVE_SECRET}
DROPBOX_KEY: ${UPPY_DROPBOX_KEY}
DROPBOX_SECRET: ${UPPY_DROPBOX_SECRET}
JWT_SECRET: ${STRAPI_JWT_SECRET} # we use strapi's JWT secret so we can verify that uploads are from account holders
STRAPI_API_KEY: ${UPPY_STRAPI_API_KEY}
STRAPI_URL: ${UPPY_STRAPI_URL}
ports:
- "3020:3020"
volumes:
- ./packages/uppy/index.js:/app/index.js

View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

47
packages/next/.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# 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 ###
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# End of https://www.toptal.com/developers/gitignore/api/nextjs

1
packages/next/.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/iron

1
packages/next/CHECKS Normal file
View File

@ -0,0 +1 @@
/ futureporn.net

View File

@ -0,0 +1,35 @@
## @greetz https://medium.com/@elifront/best-next-js-docker-compose-hot-reload-production-ready-docker-setup-28a9125ba1dc
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN apt-get update && apt-get install -y -qq dumb-init
COPY . /app
WORKDIR /app
FROM base AS deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
FROM base AS taco
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
FROM deps AS build
ENV NEXT_TELEMETRY_DISABLED 1
RUN pnpm run -r build
FROM deps AS runner
ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /app
COPY --from=build /usr/src/app/public ./public
COPY --from=build /usr/src/app/.next/standalone ./
COPY --from=build /usr/src/app/.next/static ./.next/static
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
CMD [ "dumb-init", "node", "server.js" ]

21
packages/next/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Rogier van den Berg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

27
packages/next/README.md Normal file
View File

@ -0,0 +1,27 @@
# futureporn-next
## Dev notes
When adding a new module via pnpm, docker compose needs to be restarted or something. I'm not sure the exact steps just yet, but I think it's something like the following.
```
pnpm add @uppy/react
docker compose build next
```
> fp-next | Module not found: Can't resolve '@uppy/react'
hmm... It looks like I'm missing something. Is the new package not getting into the container? Maybe it's something to do with the pnpm cache?
Must we build without cache?
docker compose build --no-cache next; docker compose up
YES. that solved the issue.
However, it's really slow to purge cache and download all packages once again. Is there a way we can speed this up?
* make it work
* make it right
* make it fast

14
packages/next/app.json Normal file
View File

@ -0,0 +1,14 @@
{
"healthchecks": {
"web": [
{
"type": "startup",
"name": "web check",
"description": "Checking for expecting string at /api",
"path": "/api",
"content": "Application Programmable Interface",
"attempts": 3
}
]
}
}

View File

@ -0,0 +1,64 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import Link from 'next/link';
// import { getProgress } from '../lib/vods'
export default async function Page() {
// const { complete, total } = await getProgress('projektmelody')
return (
<>
<div className="content">
<div className="box">
<div className="block">
<h1>About</h1>
<div className="section hero is-primary">
<p>Futureporn is a fanmade public archive of NSFW R18 vtuber livestreams.</p>
</div>
<h1>Mission</h1>
<div className="section">
<p>It&apos;s a lofty goal, but Futureporn aims to become <b>the Galaxy&apos;s best VTuber hentai site.</b></p>
</div>
<h2>How do we get there?</h2>
<div className="section">
<h3>1. Solve the viewer&apos;s common problems</h3>
<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>Viewers want to find content that interests them. Futureporn enables vod tagging for easy browsing.</p>
</div>
<div className="section">
<h3>2. Solve the streamer&apos;s common problems</h3>
<p>Platforms like PH are not rising to the needs of VTubers. Instead of offering support and resources, they restrict and ban top talent.</p>
<p>Futureporn is different, embracing the medium and leveraging emerging technologies to amplify VTuber success.</p>
</div>
<div className="section">
<h3>3. Scale beyond Earth</h3>
<p>Piggybacking on <Link href="/faq#ipfs">IPFS</Link>&apos; content-addressable capabilities and potential to end 404s, VODs preserved here can withstand the test of time, and eventually persist <Link href="/goals">off-world</Link>.</p>
</div>
<div className="section">
<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>
</div>
</>
)
}

View File

@ -0,0 +1,10 @@
import { NextResponse } from 'next/server'
export async function GET() {
const res = await fetch('https://dummyjson.com/posts', {
next: { revalidate: 60 },
});
const data = await res.json();
return NextResponse.json(data);
}

View File

@ -0,0 +1,118 @@
'use client';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import Link from 'next/link'
import { Highlight, themes } from "prism-react-renderer";
const bootstrapScript = `#!/bin/bash
## bootstrap.sh
## tested on Ubuntu 22.04
## install dependencies
cd
apt install -y screen
## Open necessary firewall ports
ufw allow 9096/tcp
ufw allow 9094/tcp
ufw allow 4001/tcp
ufw allow 4001/udp
## Download kubo
wget 'https://dist.ipfs.tech/kubo/v0.24.0/kubo_v0.24.0_linux-amd64.tar.gz'
tar xvzf ./kubo_v0.24.0_linux-amd64.tar.gz
chmod +x ./kubo/install.sh
./kubo/install.sh
## Download ipfs-cluster-follow
wget 'https://dist.ipfs.tech/ipfs-cluster-follow/v1.0.7/ipfs-cluster-follow_v1.0.7_linux-amd64.tar.gz'
tar xvzf ./ipfs-cluster-follow_v1.0.7_linux-amd64.tar.gz
chmod +x ./ipfs-cluster-follow/ipfs-cluster-follow
mv ./ipfs-cluster-follow/ipfs-cluster-follow /usr/local/bin/
## initialize ipfs
ipfs init
## run ipfs in a screen session
screen -d -m ipfs daemon
## run ipfs-cluster-follow
CLUSTER_PEERNAME="my-cluster-peer-name" ipfs-cluster-follow futureporn.net run --init https://futureporn.net/api/service.json
`
export default function Page() {
return (
<div className="content">
<div className="section">
<h1 className="title">Futureporn API</h1>
<p className="subtitle">Futureporn Application Programmable Interface (API) for developers and power users</p>
</div>
<div>
<div className="section">
<div className="box">
<h2 className="title">RSS Feed</h2>
<p className="subtitle">Keep up to date with new VODs using Real Simple Syndication (RSS).</p>
<p>Don&apos;t have a RSS reader? Futureporn recommends <Link target="_blank" href="https://fraidyc.at/">Fraidycat <FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></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>
</div>
</div>
</div>
<div className="section">
<div className="box">
<h2 className="title mb-2">Data API</h2>
<p>The Data API contains all the data served by this website in JSON format, including IPFS Content IDs (CID), VOD titles, dates, and stream announcement links.</p>
<p><Link className="mt-3 mb-5 button is-primary" href="/api/v1.json">Futureporn API Version 1</Link></p>
</div>
</div>
<div className="section">
<div className="box">
<h2 className="title mb-2">IPFS Cluster Template</h2>
<p>The IPFS Cluster Template allows other IPFS cluster instances to join the Futureporn.net IPFS cluster as a <Link target="_blank" href="https://ipfscluster.io/documentation/collaborative/joining/">follower peer <FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>. Cluster peers automatically pin (replicate) the IPFS content listed on this website.</p>
<p>Basic instructions are as follows</p>
<p>1. Download & install both <Link target="_blank" href="https://dist.ipfs.tech/#kubo"><span className="mr-1">kubo</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> and <Link target="_blank" href="https://dist.ipfs.tech/#ipfs-cluster-follow">ipfs-cluster-follow <FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> onto your server.</p>
<p>2. Initialize your ipfs repo & start the ipfs daemon</p>
<p>3. Join the cluster using ipfs-cluster-follow</p>
<p>Below is an example bash script to get everything you need to run an IPFS follower peer. This is only an example and may need tweaks to run in your environment.</p>
<Highlight
code={bootstrapScript}
language='bash'
>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre style={style}>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
<h2 className="title mb-2"><a id="cluster" className="mt-3 mb-5 button is-info" href="/api/service.json">Futureporn IPFS Cluster Template (service.json)</a></h2>
<div className="mb-5"></div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')
const tag = request.nextUrl.searchParams.get('tag')
if (!token) {
return NextResponse.json({ message: 'Missing token param' }, { status: 400})
}
if (!tag) {
return NextResponse.json({ message: 'Missing tag param' }, { status: 400 })
}
if (token !== process.env.REVALIDATION_TOKEN) {
return NextResponse.json({ message: 'Invalid token' }, { status: 401 })
}
revalidateTag(tag)
return NextResponse.json({ revalidated: true, now: Date.now() })
}

View File

@ -0,0 +1,139 @@
import { NextResponse } from 'next/server'
const serviceConfig = {
"cluster": {
"peername": "replace-this-with-a-super-cool-peer-name",
"secret": "3acade7f761c91f5fe3d34c4f4d15a17f817bc3463ab4395958f302b222a023b",
"leave_on_shutdown": false,
"listen_multiaddress": [
"/ip4/0.0.0.0/tcp/9096"
],
"connection_manager": {
"high_water": 400,
"low_water": 100,
"grace_period": "2m0s"
},
"dial_peer_timeout": "3s",
"state_sync_interval": "10m",
"pin_recover_interval": "12m",
"ipfs_sync_interval": "130s",
"replication_factor_min": -1,
"replication_factor_max": -1,
"monitor_ping_interval": "30s",
"peer_watch_interval": "10s",
"mdns_interval": "10s",
"disable_repinning": true,
"follower_mode": true,
"peer_addresses": [
"/dns4/cluster.sbtp.xyz/tcp/9096/p2p/12D3KooWJmCsFadow1UvqAqCGtuKpqrS3puyPUYujJj4dRRCTfXf"
]
},
"consensus": {
"crdt": {
"cluster_name": "futureporn.net",
"trusted_peers": [
"12D3KooWJmCsFadow1UvqAqCGtuKpqrS3puyPUYujJj4dRRCTfXf"
],
"rebroadcast_interval": "1m",
"peerset_metric": "ping",
"batching": {
"max_batch_size": 0,
"max_batch_age": "0s",
"max_queue_size": 50000
}
}
},
"ipfs_connector": {
"ipfshttp": {
"node_multiaddress": "/ip4/127.0.0.1/tcp/5001",
"connect_swarms_delay": "30s",
"ipfs_request_timeout": "5m",
"repogc_timeout": "24h",
"pin_timeout": "3m",
"unpin_timeout": "3h",
"unpin_disable": false
}
},
"pin_tracker": {
"stateless": {
"max_pin_queue_size": 1000000,
"concurrent_pins": 8,
"priority_pin_max_age" : "24h",
"priority_pin_max_retries" : 5
}
},
"monitor": {
"pubsubmon": {
"check_interval": "15s",
"failure_threshold": 3
}
},
"informer": {
"disk": {
"metric_ttl": "5m",
"metric_type": "freespace"
},
"tags": {
"metric_ttl": "30s",
"tags": {}
}
},
"allocator": {
"balanced": {
"allocate_by": ["freespace"]
}
},
"observations": {
"metrics": {
"enable_stats": false,
"prometheus_endpoint": "/ip4/0.0.0.0/tcp/8888",
"reporting_interval": "2s"
},
"tracing": {
"enable_tracing": false,
"jaeger_agent_endpoint": "/ip4/0.0.0.0/udp/6831",
"sampling_prob": 0.3,
"service_name": "cluster-daemon"
}
},
"datastore": {
"badger": {
"gc_discard_ratio": 0.2,
"gc_interval": "15m0s",
"gc_sleep": "10s",
"badger_options": {
"dir": "",
"value_dir": "",
"sync_writes": true,
"table_loading_mode": 0,
"value_log_loading_mode": 0,
"num_versions_to_keep": 1,
"max_table_size": 67108864,
"level_size_multiplier": 10,
"max_levels": 7,
"value_threshold": 32,
"num_memtables": 5,
"num_level_zero_tables": 5,
"num_level_zero_tables_stall": 10,
"level_one_size": 268435456,
"value_log_file_size": 1073741823,
"value_log_max_entries": 1000000,
"num_compactors": 2,
"compact_l_0_on_close": true,
"read_only": false,
"truncate": false
}
}
}
}
export const dynamic = 'force-dynamic'
export async function GET() {
const options = {
headers: {
"Content-Type": "application/json",
}
};
return new NextResponse(JSON.stringify(serviceConfig), options);
}

View File

@ -0,0 +1,91 @@
import { getVodTitle } from '@/components/vod-page';
import { getUrl, getAllVods } from "@/lib/vods"
import { IVod } from "@/lib/vods"
/*
* this is a legacy format
*
* for API version 1.
*
* @deprecated
*/
interface IVod1 {
title: string;
videoSrcHash: string;
video720Hash: string;
video480Hash: string;
video360Hash: string;
video240Hash: string;
thinHash: string;
thiccHash: string;
announceTitle: string;
announceUrl: string;
date: string;
note: string;
url: string;
}
interface IAPI1 {
vods: IVod1[]
}
export async function GET(): Promise<Response> {
try {
const vodsRaw = await getAllVods();
if (!vodsRaw) {
const options = {
headers: {
"Content-Type": "application/json",
},
status: 500,
};
return new Response('{}', options);
}
const vods: IVod1[] = vodsRaw.map((v: IVod): IVod1 => ({
title: getVodTitle(v),
videoSrcHash: v.attributes.videoSrcHash,
video720Hash: '',
video480Hash: '',
video360Hash: '',
video240Hash: v.attributes.video240Hash,
thinHash: '',
thiccHash: '',
announceTitle: v.attributes.announceTitle,
announceUrl: v.attributes.announceUrl,
date: v.attributes.date2,
note: v.attributes.note || '',
url: getUrl(v, v.attributes.vtuber.data.attributes.slug, v.attributes.date2),
}));
const response = {
vods: vods,
};
const options = {
headers: {
"Content-Type": "application/json",
},
};
return new Response(JSON.stringify(response), options);
} catch (error) {
console.error("Error fetching VODs:", error);
const errorResponse = {
error: "An error occurred while fetching VODs",
};
const options = {
headers: {
"Content-Type": "application/json",
},
status: 500,
};
return new Response(JSON.stringify(errorResponse), options);
}
}

View File

@ -0,0 +1,54 @@
import Link from "next/link"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"
export default async function Page() {
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 all of her 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>
)
}

View File

@ -0,0 +1,35 @@
import Link from 'next/link';
import { siteUrl } from '@/lib/constants';
import { IBlogPost } from '@/lib/blog';
export default async function PostsPage() {
const res = await fetch(`${siteUrl}/api/blogs`);
const posts: IBlogPost[] = [
{
id: 1,
slug: '2021-10-29-the-story-of-futureporn',
title: 'The Story Of Futureporn'
}
]
return (
<div className="container mb-5">
<div className="content mb-5">
<h1>All Blog Posts</h1>
<hr style={{ width: '220px' }} />
<div style={{ paddingTop: '40px' }}>
{posts.map((post: IBlogPost) => (
<article key={post.slug}>
<Link href={`/blog/${post.slug}`}>
<h2>&gt; {post.title}</h2>
</Link>
</article>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { getAllStreamsForVtuber } from "@/lib/streams";
import { IVtuber } from "@/lib/vtubers";
export interface IArchiveProgressProps {
vtuber: IVtuber;
}
export default async function ArchiveProgress ({ vtuber }: IArchiveProgressProps) {
const streams = await getAllStreamsForVtuber(vtuber.id);
const goodStreams = await getAllStreamsForVtuber(vtuber.id, ['good']);
const issueStreams = await getAllStreamsForVtuber(vtuber.id, ['issue']);
const totalStreams = streams.length;
const eligibleStreams = issueStreams.length+goodStreams.length;
// Check if totalStreams is not zero before calculating completedPercentage
const completedPercentage = (totalStreams !== 0) ? Math.round(eligibleStreams / totalStreams * 100) : 0;
return (
<div>
<p className="heading">{eligibleStreams}/{totalStreams} Streams Archived ({completedPercentage}%)</p>
<progress className="progress is-success" value={eligibleStreams} max={totalStreams}>{completedPercentage}%</progress>
</div>
)
}

View File

@ -0,0 +1,131 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPatreon } from '@fortawesome/free-brands-svg-icons';
import { useLocalStorageValue } from '@react-hookz/web';
import { faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
import Skeleton from 'react-loading-skeleton';
import { strapiUrl } from '@/lib/constants';
export interface IJWT {
jwt: string;
user: IUser;
}
export interface IUser {
id: number;
username: string;
email: string;
provider: string;
confirmed: boolean;
blocked: boolean;
createdAt: string;
updatedAt: string;
isNamePublic: boolean;
avatar: string | null;
isLinkPublic: boolean;
vanityLink: string | null;
patreonBenefits: string;
}
export interface IAuthData {
accessToken: string | null;
user: IUser | null;
}
export interface IUseAuth {
authData: IAuthData | null | undefined;
setAuthData: (data: IAuthData | null) => void;
lastVisitedPath: string | undefined;
login: () => void;
logout: () => void;
}
export const AuthContext = createContext<IUseAuth | null>(null);
interface IAuthContextProps {
children: ReactNode;
}
export function AuthProvider({ children }: IAuthContextProps): React.JSX.Element {
const { value: authData, set: setAuthData } = useLocalStorageValue<IAuthData | null>('authData', {
defaultValue: null,
});
const { value: lastVisitedPath, set: setLastVisitedPath } = useLocalStorageValue<string>('lastVisitedPath', {
defaultValue: '/profile',
initializeWithValue: false,
});
const router = useRouter();
const login = async () => {
const currentPath = window.location.pathname;
setLastVisitedPath(currentPath);
router.push(`${strapiUrl}/api/connect/patreon`);
};
const logout = () => {
setAuthData({ accessToken: null, user: null });
};
return (
<AuthContext.Provider
value={{
authData,
setAuthData,
lastVisitedPath,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function LoginButton() {
const context = useContext(AuthContext);
if (!context) return <Skeleton></Skeleton>;
const { login } = context;
return (
<button
className="button is-primary has-icons-left"
onClick={() => {
login();
}}
>
<span className="icon is-small">
<FontAwesomeIcon icon={faPatreon} className="fab fa-patreon" />
</span>
<span>Login</span>
</button>
);
}
export function LogoutButton() {
const context = useContext(AuthContext);
if (!context) return <></>;
const { logout } = context;
return (
<button
className="button is-secondary has-icons-left"
onClick={() => {
logout();
}}
>
<span className="icon is-small">
<FontAwesomeIcon icon={faRightFromBracket} className="fas fa-right-from-bracket" />
</span>
<span>Logout</span>
</button>
);
}
export function useAuth(): IUseAuth {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@ -0,0 +1,125 @@
'use client';
// greets https://github.com/wa0x6e/cal-heatmap-react-starter/blob/main/src/components/cal-heatmap.tsx
import CalHeatmap from 'cal-heatmap';
// @ts-ignore cal-heatmap is jenk
import Legend from 'cal-heatmap/plugins/Legend';
// @ts-ignore cal-heatmap is jenk
import Tooltip from 'cal-heatmap/plugins/Tooltip';
import { DataRecord } from 'cal-heatmap/src/options/Options';
import 'cal-heatmap/cal-heatmap.css';
import dayjs from 'dayjs';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { getSafeDate } from '@/lib/dates';
export interface ICalProps {
data: DataRecord[];
slug: string;
}
export function Cal({ data, slug }: ICalProps) {
const router = useRouter();
const [cellSize, setCellSize] = useState(13);
const [targetElementId, setTargetElementId] = useState('');
const generateUniqueId = () => {
return `cal-${Math.random().toString(36).substring(2, 9)}`;
};
useEffect(() => {
const updateCellSize = () => {
const windowWidth = window.innerWidth;
if (windowWidth > 1400) {
setCellSize(15); // Adjust the cell size for width > 1400px
} else if (windowWidth > 730) {
setCellSize(10); // Adjust the cell size for width > 730px
} else {
setCellSize(3); // Adjust the cell size for width <= 730px
}
}
updateCellSize();
// Event listener to update cell size on window resize
window.addEventListener('resize', updateCellSize);
return () => {
window.removeEventListener('resize', updateCellSize);
};
}, [])
useEffect(() => {
setTargetElementId(generateUniqueId());
}, []);
useEffect(() => {
if (!targetElementId) return;
const cal = new CalHeatmap();
// @ts-ignore
cal.on('click', (
event: string,
timestamp: number,
value: number
) => {
router.push(`/vt/${slug}/stream/${getSafeDate(new Date(timestamp))}`);
// console.log(`slug=${slug} safeDate=${getSafeDate(new Date(timestamp))}`);
});
cal.paint(
{
itemSelector: `#${targetElementId}`,
scale: {
color: {
// @ts-ignore this shit is straight from the example website
domain: ['missing', 'issue', 'good'],
type: 'ordinal',
range: ['red', 'yellow', 'green']
}
},
theme: 'dark',
verticalOrientation: false,
data: {
source: data,
x: 'date',
y: 'value',
// @ts-ignore this shit is straight from the example website
groupY: d => d[0]
},
range: 12,
date: { start: data[0].date },
domain: {
type: 'month',
gutter: 4,
label: { text: 'MMM', textAlign: 'start', position: 'top' }
},
subDomain: {
type: 'ghDay',
radius: 2,
width: cellSize,
height: cellSize,
gutter: 4,
}
}, [
[
Tooltip,
{
text: ((ts: number, value: string, dayjsDate: dayjs.Dayjs) => {
return `${!!value ? value+' - '+dayjsDate.toString() : dayjsDate.toString() }`;
})
}
]
]);
}, [targetElementId, data, cellSize, router, slug]);
return (
<>
<div id={targetElementId}></div>
</>
)
}

View File

@ -0,0 +1,33 @@
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} />
</SkeletonTheme>
)
const contributorList = contributors.map((contributor, index) => (
<span key={index}>
{contributor.attributes.url ? (
<Link href={contributor.attributes.url} target="_blank">
<span className="mr-1">{contributor.attributes.name}</span>
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon>
</Link>
) : (
contributor.attributes.name
)}
{index !== contributors.length - 1 ? ", " : ""}
</span>
));
return (
<>{contributorList}</>
)
}

View File

@ -0,0 +1,80 @@
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 };
};
const CustomPlyrInstance = forwardRef<
APITypes,
PlyrProps & { hlsSource: string; mainColor: string; plyrOptions: Options }
>((props, ref) => {
const { source, plyrOptions, hlsSource, mainColor } = props;
const plyrRef = usePlyr(ref, {
...useHls(hlsSource, plyrOptions),
source,
}) as MutableRefObject<HTMLVideoElement>;
return (
<>
<video
ref={plyrRef}
className="plyr-react plyr"
style={{ "--plyr-color-main": mainColor } as React.CSSProperties}
></video>
</>
);
});
CustomPlyrInstance.displayName = 'CustomPlyrInstance'
export { CustomPlyrInstance }

View File

@ -0,0 +1,118 @@
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { faGit, faReddit, faDiscord, faPatreon } from "@fortawesome/free-brands-svg-icons";
import Contributors from "./contributors";
import PatronsList from "./patrons-list";
export default function Footer() {
return (
<>
<footer className="footer">
<div className="content">
<div className="columns is-multiline is-mobile">
<div className="column is-12">
<p className="mt-4 heading">Sitemap</p>
<ul>
<li><Link href="#top">&uarr; Top of page</Link></li>
<li><Link href="/vt">Vtubers</Link></li>
<li><Link href="/streams">Stream Archive</Link></li>
<li><Link href="/about">About</Link></li>
<li><Link href="/faq">FAQ</Link></li>
<li><Link href="/goals">Goals</Link></li>
<li><Link href="/patrons">Patrons</Link></li>
<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="/blog">Blog</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>
</div>
<div className="column is-12">
<p className="mt-4">
Futureporn.net is made with by <Link target="_blank" href="https://twitter.com/CJ_Clippy">CJ_Clippy <FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon></Link>
</p>
<p>
Made possible by generous <Link
href="https://patreon.com/CJ_Clippy"
target="_blank"
>
<span> donations </span>
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon>
</Link>
<span> from</span> <PatronsList displayStyle="list" />
</p>
<p>
VOD contributions by <Contributors />
</p>
<p>
<Link
href="https://gitea.futureporn.net/futureporn"
target="_blank"
>
<FontAwesomeIcon
icon={faGit}
className="fab fa-git"
></FontAwesomeIcon>
<span> Git Repo </span>
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon>
</Link>
</p>
<p>
<Link
href="https://www.reddit.com/r/projektmelody/comments/qikzy0/futureporn_an_unofficial_projektmelody_chaturbate/"
target="_blank"
>
<FontAwesomeIcon
icon={faReddit}
className="fab fa-reddit"
></FontAwesomeIcon>
<span> Reddit Thread </span>
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon>
</Link>
</p>
<p>
<Link
href="https://discord.gg/wrZQnK3M8z"
target="_blank"
>
<FontAwesomeIcon
icon={faDiscord}
className="fab fa-discord"
></FontAwesomeIcon>
<span> Discord Server </span>
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon>
</Link>
</p>
</div>
</div>
</div>
<script data-goatcounter="https://futureporn.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
</footer>
</>
)
}

View File

@ -0,0 +1,81 @@
import { getCampaign } from "@/lib/patreon";
import { getGoals, IGoals } from '@/lib/pm'
import Image from 'next/image';
import React from 'react';
import Link from 'next/link'
export default async function FundingGoal(): Promise<React.JSX.Element> {
const campaignData = await getCampaign();
const { pledgeSum, patronCount } = campaignData;
const goals = await getGoals(pledgeSum);
if (!goals || !goals?.featuredFunded?.amountCents || !goals?.featuredUnfunded?.amountCents || !goals?.featuredFunded?.amountCents || !goals?.featuredUnfunded?.completedPercentage || !goals?.featuredFunded?.completedPercentage ) return <></>
return (
<>
{/* <p>
pledgeSum:{JSON.stringify(pledgeSum, null, 2)}
</p>
<p>
patronCount:{JSON.stringify(patronCount, null, 2)}
</p>
<p>featuredFunded:{JSON.stringify(goals.featuredFunded)}</p>
<p>featuredUnfunded:{JSON.stringify(goals.featuredUnfunded)}</p> */}
{/* <pre>
<code>
{JSON.stringify(goals, null, 2)}
</code>
</pre> */}
<article className="message is-info">
<div className="message-header">
Funding Goal
<figure className="image is-32x32 is-rounded">
<Link target="_blank" href="https://twitter.com/cj_clippy">
<Image className="is-rounded" src="https://futureporn-b2.b-cdn.net/cj_clippy.jpg" alt="CJ_Clippy" fill />
</Link>
</figure>
</div>
<div className="message-body has-text-centered">
<div className="columns">
{/* the most recently funded goal */}
<div className="column is-one-third">
{/* const { featuredFunded, featuredUnfunded } = goals;
if (!featuredFunded?.amountCents || !featuredFunded?.completedPercentage) return <></>
if (!featuredUnfunded?.amountCents || !featuredUnfunded?.completedPercentage) return <></> */}
<p className="subtitle">${(goals.featuredFunded.amountCents * (goals.featuredFunded.completedPercentage * 0.01) / 100)} of {goals.featuredFunded.amountCents / 100} ({goals.featuredFunded.completedPercentage}%)
</p>
<div className="mb-5 tag is-success is-rounded" style={{ width: '100%' }}>
FUNDED
</div>
<p>{goals.featuredFunded.description}</p>
</div>
{/* the next unfunded goal */}
<div className="column is-two-thirds">
<p className="subtitle">${(goals.featuredUnfunded.amountCents * (goals.featuredUnfunded.completedPercentage * 0.01) / 100) | 0} of ${goals.featuredUnfunded.amountCents / 100} ({goals.featuredUnfunded.completedPercentage}%)</p>
<progress
className="progress is-info is-large"
value={goals.featuredUnfunded.completedPercentage}
max="100"
>
{goals.featuredUnfunded.completedPercentage}%
</progress>
<p>{goals.featuredUnfunded.description}</p>
</div>
</div>
<p className="mt-3 subtitle is-4">
Thank you, <Link href="/patrons">Patrons!</Link>
</p>
</div>
</article>
</>
);
};

View File

@ -0,0 +1,8 @@
import * as React from "react"
const SvgComponent = (props: any) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 552" {...props}>
<title>{"Carrd"}</title>
<path d="M446.1 83.3c-1.2-.7-2.6-.8-3.9-.2l-130.7 62.7L37.7 14.4c-1.2-.6-2.7-.5-3.9.2-1.2.7-1.9 2-1.9 3.4v324.5c0 1.6 1 3.1 2.5 3.7l143.8 60.2c.5.2 1 .3 1.5.3V536c0 1.4.7 2.7 1.9 3.4.6.4 1.4.6 2.1.6.6 0 1.2-.1 1.7-.4l260.1-124.8c1.4-.7 2.3-2.1 2.3-3.6V86.6c.2-1.3-.5-2.6-1.7-3.3zM40 339.8V24.4l262.3 125.8-78.2 37.5c-.4-.6-.9-1.1-1.6-1.4l-122.4-55.7c-2-.9-4.4 0-5.3 2-.9 2 0 4.4 2 5.3l118.6 54-33.3 16c-1.4.7-2.3 2.1-2.3 3.6v26.2l-80-32.2c-2.1-.8-4.4.2-5.2 2.2-.8 2 .2 4.4 2.2 5.2l83 33.4v66.3l-80-32.2c-2.1-.8-4.4.2-5.2 2.2-.8 2 .2 4.4 2.2 5.2l83 33.4v77.2L40 339.8zm400 68.8-252.1 121V214L440 93v315.6zM242.7 273.3c-1-2-.1-4.4 1.9-5.3l135.3-65.4c2-1 4.4-.1 5.3 1.9 1 2 .1 4.4-1.9 5.3L248 275.2c-.6.3-1.2.4-1.7.4-1.5 0-3-.8-3.6-2.3zm0 74.9c-1-2-.1-4.4 1.9-5.3l135.3-65.4c2-1 4.4-.1 5.3 1.9 1 2 .1 4.4-1.9 5.3L248 350.1c-.6.3-1.2.4-1.7.4-1.5 0-3-.9-3.6-2.3zm0 74.9c-1-2-.1-4.4 1.9-5.3l135.3-65.4c2-1 4.4-.1 5.3 1.9 1 2 .1 4.4-1.9 5.3L248 425c-.6.3-1.2.4-1.7.4-1.5 0-3-.9-3.6-2.3z" />
</svg>
)
export default SvgComponent

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,20 @@
import * as React from "react"
const SvgComponent = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
baseProfile="tiny"
viewBox="0 0 394.7 324.7"
{...props}
>
<path
fill="#2799F6"
d="M231.9 95.6c1.5 1.5 2.7 2.9 4 4.2 30.6 30.6 61.3 61.2 91.9 91.9 4.9 4.9 4.9 5.9-.1 10.9-38.2 38.2-76.4 76.3-114.6 114.5-10.3 10.3-20.6 10.3-30.9 0-51.5-51.6-103.4-102.6-154.3-154.7C.3 134.2-6.9 99.8 6.6 62.8 19.7 26.7 46.7 5.8 84.9.9c31.2-4 58 6.1 80.3 28.3 6.6 6.6 13.1 13.3 19.8 19.7 3.4 3.3 3.2 6-.1 9.2-8.5 8.3-16.9 16.6-25.1 25.1-3.4 3.5-6 3.2-9.2-.2-6.8-7.1-13.9-13.9-20.8-20.9-18.7-18.6-48.6-18.5-67.3.2-18.4 18.4-18.2 48.2.3 66.8 10.9 11 21.8 22 33.2 33.4 1.5-1.4 2.8-2.6 4-3.9 43-42.7 85.9-85.6 128.8-128.5 14.8-14.8 32-24.9 52.7-28.6 63.4-11.3 116.9 39.7 113 99.8-1.6 24.1-10.9 44.7-27.6 61.9-7 7.2-14.2 14.1-21.2 21.2-3.1 3.1-5.8 3.3-8.9.1-8.4-8.5-16.9-17-25.5-25.4-3.1-3.1-3.3-5.7 0-8.8 7.1-6.8 14-13.8 20.9-20.8 18.8-19.1 19-48.6.5-67.4-18.3-18.5-48.5-18.6-67.4 0-11.2 11.1-22.1 22.2-33.4 33.5zm-81.6 100.9c0 26.5 21 48 47.1 48.1 25.9.2 47.8-21.6 47.9-47.6.1-26.1-21.4-47.3-47.9-47.3-26.2 0-47.1 20.8-47.1 46.8z"
/>
<path
fill="#2899F6"
d="M206.3 168.3c-2.7 5.4-1.7 10.4 2.7 14.4 4.7 4.2 10 4 15.1.4 7.8 10.8 3.6 27.7-5.1 35.9-10.7 10.1-28.7 11.2-39.9 2.2-12.1-9.7-15.4-26.3-7.8-39.4 7.1-12.6 22.5-18.6 35-13.5z"
/>
</svg>
)
export default SvgComponent

View File

@ -0,0 +1,30 @@
import * as React from "react"
const SvgComponent = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
viewBox="0 0 301.557 368.345"
{...props}
>
<style id="style8950" type="text/css">
{".st0{fill-rule:evenodd;clip-rule:evenodd;fill:#43e660}"}
</style>
<g id="layer1" transform="translate(-239.83 -113.362)">
<g id="g46" transform="matrix(.26458 0 0 .26458 -15.675 43.648)">
<g id="g18" transform="matrix(.85714 0 0 .85714 8 306)">
<path
id="path993"
d="M1666.4 1043.3h236.4v531.3h-236.4v-531.3"
className="st0"
/>
<path
id="path991"
d="M1119.8 499.4h401.7l-286-270.7 157.6-160.6 272.1 278.3v-396h236.4v395.9l272-278.3 157.6 160.6-286 270.7H2447v223.9h-404.3l287.3 278.3-157.6 156.9-390.3-390.9-390.3 390.9-157.6-156.9 287.3-278.3h-404.2V499.4h2.5"
className="st0"
/>
</g>
</g>
</g>
</svg>
)
export default SvgComponent

View File

@ -0,0 +1,55 @@
import * as React from "react"
const SvgComponent = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
viewBox="0 0 256 256"
{...props}
>
<g
style={{
stroke: "none",
strokeWidth: 0,
strokeDasharray: "none",
strokeLinecap: "butt",
strokeLinejoin: "miter",
strokeMiterlimit: 10,
fill: "none",
fillRule: "nonzero",
opacity: 1,
}}
>
<path
d="M30 15C13.431 15 0 28.431 0 45s13.431 30 30 30 30-13.431 30-30-13.431-30-30-30zm0 39c-4.971 0-9-4.029-9-9s4.029-9 9-9 9 4.029 9 9a8.988 8.988 0 0 1-8.976 9H30z"
style={{
stroke: "none",
strokeWidth: 1,
strokeDasharray: "none",
strokeLinecap: "butt",
strokeLinejoin: "miter",
strokeMiterlimit: 10,
fill: "#00aeef",
fillRule: "nonzero",
opacity: 1,
}}
transform="matrix(2.81 0 0 2.81 1.407 1.407)"
/>
<path
d="M63.72 37.5c7.622 2.194 16.62 0 16.62 0-2.611 11.4-10.891 18.54-22.831 19.409A29.935 29.935 0 0 1 30 75l9-28.606C48.252 16.992 52.994 15 74.935 15H90c-2.52 11.1-11.206 19.579-26.28 22.5z"
style={{
stroke: "none",
strokeWidth: 1,
strokeDasharray: "none",
strokeLinecap: "butt",
strokeLinejoin: "miter",
strokeMiterlimit: 10,
fill: "#008ccf",
fillRule: "nonzero",
opacity: 1,
}}
transform="matrix(2.81 0 0 2.81 1.407 1.407)"
/>
</g>
</svg>
)
export default SvgComponent

View File

@ -0,0 +1,23 @@
import * as React from "react"
const SvgComponent = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
overflow="visible"
viewBox="0 0 79.6 84.5"
{...props}
>
<path
fill="#f7971d"
d="M64 55.701c-.163 0-.323.01-.482.024a4.163 4.163 0 0 0-2.566 1.191 4.72 4.72 0 0 0-.67.82 5.891 5.891 0 0 0-.52 1.017c-.073.187-.14.382-.2.585a8.081 8.081 0 0 0-.156.635 11.138 11.138 0 0 0-.183 1.432c-.015.256-.023.521-.023.796 0 .288.01.564.019.83a13.589 13.589 0 0 0 .145 1.473 10.005 10.005 0 0 0 .281 1.238c.058.188.12.367.188.537s.14.331.219.485c.077.153.162.3.25.437.124.188.254.364.388.53s.274.32.418.462a4.954 4.954 0 0 0 .686.567 4.428 4.428 0 0 0 1.027.51 4.188 4.188 0 0 0 .565.146 4.7 4.7 0 0 0 1.36.06 3.928 3.928 0 0 0 .856-.194 3.835 3.835 0 0 0 1.188-.668 5.32 5.32 0 0 0 .71-.715c.113-.137.22-.284.32-.44a5.987 5.987 0 0 0 .52-1.055c.072-.196.138-.402.198-.618.059-.215.113-.44.158-.675.045-.237.083-.484.113-.74.03-.258.053-.525.068-.803S68.9 63 68.9 62.7c0-.312-.01-.615-.023-.906-.015-.29-.038-.569-.068-.838-.03-.269-.068-.526-.113-.773a9.454 9.454 0 0 0-.158-.71 7.443 7.443 0 0 0-.2-.64 6.206 6.206 0 0 0-.24-.578 5.182 5.182 0 0 0-.279-.51 4.375 4.375 0 0 0-.67-.83 4.334 4.334 0 0 0-.781-.605 4.094 4.094 0 0 0-.883-.395 4.373 4.373 0 0 0-.969-.19 5.462 5.462 0 0 0-.515-.024z"
/>
<path
fill="#f7971d"
d="M3.7 34.9C1.5 35.2.2 36.5 0 39v41.4c.2 2.5 1.4 3.9 3.7 4.1h72.2c2.2-.3 3.5-1.6 3.7-4.1V39c-.4-2.5-1.6-3.8-3.8-4.1zm3.2 8.6h5.8v9.8c.187-.175.4-.339.632-.492.233-.153.488-.295.758-.425.27-.131.554-.25.851-.358.297-.108.607-.203.922-.287a13.084 13.084 0 0 1 2.909-.437c.317-.012.627-.012.927 0h.102a7.306 7.306 0 0 1 1.674.2c.262.061.518.138.771.222.253.084.504.177.754.277.275.125.532.258.77.397.237.139.456.285.656.441.2.156.38.322.543.498.162.177.306.365.431.565.15.2.282.406.399.617.117.21.22.426.314.644.188.438.337.888.487 1.338.05.225.093.483.13.772.037.289.07.61.094.967.05.712.076 1.562.076 2.562V73.5h-5.8V62c0-1.1-.024-2.025-.074-2.75a12.538 12.538 0 0 0-.094-.934 3.678 3.678 0 0 0-.131-.615 7.953 7.953 0 0 0-.23-.431 4.03 4.03 0 0 0-.259-.395 2.93 2.93 0 0 0-.712-.674 4.894 4.894 0 0 0-.975-.363A4.533 4.533 0 0 0 17.5 55.7c-.25 0-.487.013-.715.04s-.447.066-.66.122-.418.128-.621.217a4.685 4.685 0 0 0-1.164.733c-.174.15-.333.313-.477.488a3.755 3.755 0 0 0-.664 1.2c-.075.25-.142.525-.203.825s-.114.625-.158.975-.078.725-.102 1.125c-.024.4-.037.825-.037 1.275v10.8H6.901zm46.5 0h5.8l-.3 9.7c.175-.15.375-.294.596-.433.22-.139.463-.273.72-.398.259-.125.531-.242.817-.35a11.99 11.99 0 0 1 2.787-.676c.32-.037.641-.06.955-.068.315-.01.624 0 .924.025.7.1 1.301.2 1.801.3a11.221 11.221 0 0 1 1.961.628 8.858 8.858 0 0 1 1.2.64 7.428 7.428 0 0 1 1.066.835c.164.156.322.323.472.498.213.237.413.488.6.754a8.745 8.745 0 0 1 .959 1.752c.132.318.25.65.353.994.103.343.193.7.266 1.068.073.368.13.749.172 1.14.04.393.065.796.074 1.212.01.415 0 .841-.023 1.279 0 .462-.015.912-.043 1.35s-.07.861-.125 1.273-.127.812-.211 1.197c-.084.386-.183.758-.295 1.117a11.4 11.4 0 0 1-.38 1.038c-.141.332-.295.651-.464.957a9.295 9.295 0 0 1-.549.877 8.678 8.678 0 0 1-.632.79c-.213.25-.433.485-.66.704-.228.218-.463.422-.706.61A8.24 8.24 0 0 1 69 73.25a7.837 7.837 0 0 1-1.683.562A8.52 8.52 0 0 1 65.5 74H65c-.375 0-.734-.017-1.078-.05s-.674-.078-.988-.138a9.72 9.72 0 0 1-1.71-.488 9.003 9.003 0 0 1-1.37-.674 8.581 8.581 0 0 1-1.053-.75v1.6h-5.4zm-9.3 8.3h5.8v21.9h-5.3v-2.4a7.502 7.502 0 0 1-.875.902 7.421 7.421 0 0 1-1.325.922c-.262.144-.547.28-.855.405a8.63 8.63 0 0 1-.994.33 9.85 9.85 0 0 1-1.147.232c-.408.058-.842.097-1.304.11h-.7c-.35 0-.693-.026-1.03-.073s-.67-.115-.995-.203c-.325-.088-.645-.194-.957-.316s-.618-.258-.918-.408c-.275-.15-.538-.32-.785-.508s-.477-.392-.69-.617A5.24 5.24 0 0 1 31 70.5a6.686 6.686 0 0 1-.336-.932 10.709 10.709 0 0 1-.252-1.131c-.069-.406-.122-.841-.158-1.299s-.055-.937-.055-1.437V51.9h5.8v10c0 1.5.025 2.724.075 3.662.025.469.056.866.094 1.19.037.323.083.572.132.747a4.036 4.036 0 0 0 .523.912 4.053 4.053 0 0 0 .676.688c.126.1.263.189.413.264s.313.137.488.187.363.086.563.111c.2.025.411.04.636.04s.45-.02.674-.056c.224-.036.445-.089.664-.158.219-.069.434-.153.645-.252.21-.099.417-.21.617-.336s.383-.261.547-.41c.164-.148.31-.308.441-.476.131-.17.246-.348.348-.534s.19-.38.265-.58.136-.476.188-.824c.052-.348.094-.77.125-1.264.03-.493.052-1.059.066-1.695.014-.636.02-1.342.02-2.117z"
/>
<g fill="#fff">
<path d="M0 0v27.5h5.6V17.1h3.7c2.5 0 4.4-.198 5.7-.398 1-.2 1.9-.601 2.9-1.301s1.7-1.6 2.4-2.7c.6-1.1 1-2.6 1-4.3 0-2.2-.5-4-1.6-5.4C18.5 1.7 17.1.8 15.5.4c-1-.3-3.2-.4-6.6-.4zm5.7 4.701h2.7c.5 0 .955 0 1.37.016a36.231 36.231 0 0 1 1.992.11c.251.023.463.048.638.073a5.904 5.904 0 0 1 .655.188 4.573 4.573 0 0 1 1.41.797c.08.069.16.14.234.214a2.643 2.643 0 0 1 .395.502 3.427 3.427 0 0 1 .45 1.228 3.732 3.732 0 0 1 .056.67c-.05.226-.099.438-.152.64-.053.201-.11.393-.172.574-.062.181-.131.353-.21.517a3.98 3.98 0 0 1-.265.471 3.132 3.132 0 0 1-.53.6c-.066.06-.136.117-.208.173-.144.113-.296.22-.457.323s-.331.202-.506.302c-.088.05-.186.095-.299.133a3.348 3.348 0 0 1-.375.1 5.363 5.363 0 0 1-.445.07c-.16.02-.333.036-.518.05-.184.012-.38.02-.588.026-.207.01-.427.012-.658.016-.46.01-.967.01-1.517.01h-3zM32.5 7.201c-1.9 0-3.7.399-5.3 1.299s-2.8 2.101-3.7 3.701c-.9 1.7-1.3 3.3-1.3 5.1 0 2.3.4 4.299 1.3 5.799.9 1.6 2.1 2.8 3.7 3.7 1.7.8 3.4 1.2 5.2 1.2 3 0 5.4-1 7.3-3s3-4.4 3-7.5c0-3-1-5.5-2.9-7.4C38 8.2 35.6 7.2 32.5 7.2zm0 4.299c.175 0 .346.01.512.024a4.695 4.695 0 0 1 .947.193 4.182 4.182 0 0 1 1.27.67A6.262 6.262 0 0 1 36 13.1a3.825 3.825 0 0 1 .5.615 4.131 4.131 0 0 1 .383.72 4.834 4.834 0 0 1 .191.54c.056.187.106.383.147.586s.075.414.101.633c.027.219.046.445.06.68s.018.476.018.726-.01.49-.019.723a9.765 9.765 0 0 1-.059.67 8.259 8.259 0 0 1-.101.62 6.302 6.302 0 0 1-.338 1.108 5.179 5.179 0 0 1-.242.494 5.454 5.454 0 0 1-.293.46A5.226 5.226 0 0 1 36 22.1c-.125.124-.252.244-.38.356a6.163 6.163 0 0 1-.798.59 4.721 4.721 0 0 1-.863.41 4.151 4.151 0 0 1-.947.215c-.166.018-.337.03-.512.03s-.346-.01-.512-.024a4.7 4.7 0 0 1-.484-.072 4.14 4.14 0 0 1-1.326-.516 4.567 4.567 0 0 1-.797-.605A6.277 6.277 0 0 1 29 22.1a3.872 3.872 0 0 1-.5-.615 4.07 4.07 0 0 1-.268-.469 5.134 5.134 0 0 1-.216-.515 6.668 6.668 0 0 1-.236-.861 8.157 8.157 0 0 1-.102-.634c-.027-.218-.046-.445-.06-.68s-.018-.476-.018-.726.01-.492.019-.726c.013-.235.032-.461.059-.68a8.15 8.15 0 0 1 .101-.633 6.464 6.464 0 0 1 .237-.861 4.693 4.693 0 0 1 .343-.756A4.061 4.061 0 0 1 29 13.1a7.655 7.655 0 0 1 .771-.672 5.232 5.232 0 0 1 .829-.502 4.36 4.36 0 0 1 .904-.316 4.216 4.216 0 0 1 .996-.109zM54 7.2c-1.6.2-3 1.5-3.7 2.3V7.6h-4.9v19.9h5.3v-6.2c0-3.4.2-5.7.4-6.7.3-1 .7-1.8 1.2-2.2s1.1-.6 1.9-.6 1.6.3 2.4.9l1.7-4.6c-1.1-.7-2.3-1-3.5-1-.2.1-.5.1-.8.1M79.2 11.6c-.2-.8-.5-1.6-1-2.2S77 8.2 76 7.8s-2-.6-3.2-.6c-2.1-.1-5.6.7-6.5 2.4V7.7h-4.9v19.9h5.3v-9.1c0-2.3.2-3.7.4-4.6.3-.8.8-1.5 1.5-2s1.6-.8 2.4-.8c.7 0 1.3.2 1.8.5s.9.9 1.1 1.5c.3.6.3 2 .3 4.2v10.2h5.3V15.1c0-1.5-.1-2.7-.3-3.5" />
</g>
</svg>
)
export default SvgComponent

View File

@ -0,0 +1,31 @@
import * as React from "react"
const SvgComponent = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 67 58"
fill="none"
{...props}
>
<path
fill="url(#a)"
d="M47.248 30.46c-.23.465-.532.885-.895 1.253L34.35 44.017c-.208.29-.567.474-.96.474s-.754-.183-.986-.505L20.42 31.711a2.92 2.92 0 0 1-.18-.192c-1.255-1.41-1.496-3.446-.611-5.181L32.296 1.472a1.282 1.282 0 0 1 1.093-.598c.445 0 .845.214 1.08.573l6.348 12.464 6.349 12.464c.667 1.307.698 2.836.082 4.086ZM33.773 52.015 2.145 19.801l-.1-.103a1.247 1.247 0 0 0-.794-.284c-.424.017-.796.202-1.026.507-.215.286-.278.657-.18 1.05l9.204 31.73c.22.758.576 1.462 1.066 2.086.058.08.124.157.187.232 1.093 1.269 2.742 1.965 4.45 1.965.664 0 1.335-.103 1.986-.324a52.144 52.144 0 0 1 14.758-2.614c.429-.033.855-.06 1.279-.08.35-.019.676-.188.887-.462.208-.268.288-.605.225-.931a1.142 1.142 0 0 0-.312-.556l-.002-.003Zm32.447-32.4c-.4-.275-.91-.258-1.445.06l-.13.08-27.196 27.748a1.166 1.166 0 0 0-.005 1.642l4.74 4.855a4.082 4.082 0 0 0 2.09 1.142 51.405 51.405 0 0 1 6.343 1.768 4.48 4.48 0 0 0 1.477.249c.62 0 1.241-.127 1.834-.38a6.2 6.2 0 0 0 2.552-1.91 6.202 6.202 0 0 0 1.068-2.089l9.206-31.744c.037-.148.051-.296.04-.422a1.19 1.19 0 0 0-.574-1.004v.005Z"
/>
<defs>
<linearGradient
id="a"
x1={9.812}
x2={53.995}
y1={57.158}
y2={12.678}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#4CA6FF" />
<stop offset={0.277} stopColor="#4D4DFF" />
<stop offset={0.492} stopColor="#990AFF" />
<stop offset={0.643} stopColor="#D21EB4" />
<stop offset={1} stopColor="#FFC400" />
</linearGradient>
</defs>
</svg>
)
export default SvgComponent

View File

@ -0,0 +1,42 @@
'use client';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
import { useState } from "react";
import styles from '@/assets/styles/cid.module.css'
interface IIpfsCidProps {
label?: string;
cid: string;
}
export function IpfsCid({ label, cid }: IIpfsCidProps) {
const [isCopied, setIsCopied] = useState(false);
return (
<div className={`mb-1 ${styles.container}`}>
<span className={`heading mr-1 mb-0 ${styles.label}`}>{label} </span>
<pre className={`mr-5 px-1 py-0 ${styles.cid}`}><code>{cid}</code></pre>
{(isCopied) ?
<FontAwesomeIcon
icon={faCheck}
className={`fas fa-check mr-3 ${styles.green}`}
></FontAwesomeIcon>
:
<FontAwesomeIcon
icon={faCopy}
className="fas fa-copy mr-3"
onClick={() => {
navigator.clipboard.writeText(cid)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 3000)
}}
></FontAwesomeIcon>
}
</div>
)
}

View File

@ -0,0 +1,18 @@
import React from 'react';
interface LogoProps {
size: number;
color: string;
}
const IPFSLogo: React.FC<LogoProps> = ({ size = 32, color = '#65C2CB' }) => {
return (
<svg width={size} height={size} fill={color} role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>IPFS</title>
<path d="M12 0L1.608 6v12L12 24l10.392-6V6zm-1.073 1.445h.001a1.8 1.8 0 002.138 0l7.534 4.35a1.794 1.794 0 000 .403l-7.535 4.35a1.8 1.8 0 00-2.137 0l-7.536-4.35a1.795 1.795 0 000-.402zM21.324 7.4c.109.08.226.147.349.201v8.7a1.8 1.8 0 00-1.069 1.852l-7.535 4.35a1.8 1.8 0 00-.349-.2l-.009-8.653a1.8 1.8 0 001.07-1.851zm-18.648.048l7.535 4.35a1.8 1.8 0 001.069 1.852v8.7c-.124.054-.24.122-.349.202l-7.535-4.35a1.8 1.8 0 00-1.069-1.852v-8.7c.124-.054.24-.122.35-.202z"/>
</svg>
);
};
export default IPFSLogo;

View File

@ -0,0 +1,39 @@
'use client';
// import { type Helia, createHelia } from 'helia';
// import React, { useState, useEffect } from 'react';
// export default function Ipfs () {
// const [id, setId] = useState<string|null>(null)
// const [helia, setHelia] = useState<Helia|null>(null)
// const [isOnline, setIsOnline] = useState(false)
// 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>
// </div>
// )
// }

View File

@ -0,0 +1,28 @@
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition, faLink } from "@fortawesome/free-solid-svg-icons";
interface ILinkableHeadingProps {
icon?: IconDefinition;
text: string;
slug: string;
}
export default function LinkableHeading({ icon, text, slug }: ILinkableHeadingProps) {
return (
<h4
id={slug}
className='title is-4 mb-5'
>
{icon && <FontAwesomeIcon icon={icon} className="mr-2"></FontAwesomeIcon>}
<span>{text}</span>
<a id={slug}></a>
<Link href={`#${slug}`}>
<FontAwesomeIcon
icon={faLink}
className="fab fa-link ml-2"
></FontAwesomeIcon>
</Link>
</h4>
)
}

View File

@ -0,0 +1,15 @@
import { formatISO } from "date-fns";
interface ILocalizedDateProps {
date: Date;
}
export function LocalizedDate ({ date }: ILocalizedDateProps) {
const isoDateTime = formatISO(date);
const isoDate = formatISO(date, { representation: 'date' });
return (
<>
<time dateTime={isoDateTime}>{isoDate}</time>
</>
)
}

View File

@ -0,0 +1,98 @@
'use client'
import { useEffect, useState } from 'react'
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { faUser, faUpload } from "@fortawesome/free-solid-svg-icons";
import Link from 'next/link'
import { LoginButton, useAuth } from '@/components/auth'
export default function Navbar() {
const [isExpanded, setExpanded] = useState(false);
const [isProfileButton, setIsProfileButton] = useState(false);
const handleBurgerClick = () => {
setExpanded(!isExpanded);
};
const { authData } = useAuth()
useEffect(() => {
if (!!authData?.accessToken && !!authData?.user?.username) setIsProfileButton(true)
else setIsProfileButton(false)
}, [authData])
return (
<>
<nav className="navbar" role="navigation" aria-label="main navigation">
<div className="navbar-brand">
<Link className="navbar-item" href="/">
<h1 className="title">🔞💦 Futureporn.net</h1>
</Link>
<button
className="navbar-burger"
onClick={handleBurgerClick}
>
<span></span>
<span></span>
<span></span>
</button>
</div>
<div className={`navbar-menu ${isExpanded ? 'is-active' : ''}`} id="navMenu">
<div className='navbar-start'>
<Link className="navbar-item is-expanded" href="/vt">Vtubers</Link>
<Link className="navbar-item is-expanded" href="/streams">Stream Archive</Link>
<Link className="navbar-item is-expanded" href="/about">About</Link>
<Link className="navbar-item is-expanded" href="/faq">FAQ</Link>
<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="/api">API</Link>
</div>
<div className='navbar-end'>
<div className="navbar-item is-expanded">
<Link target="_blank" href="https://status.futureporn.net">
<span>Status </span>
<FontAwesomeIcon
icon={faExternalLinkAlt}
className="fab fa-external-link-alt"
></FontAwesomeIcon>
</Link>
</div>
{/* <div className="navbar-item">
<Link className="button " href="/upload">
<span className="mr-1">Upload</span>
<FontAwesomeIcon
icon={faUpload}
className="fas fa-upload"
></FontAwesomeIcon>
</Link>
</div> */}
<div className="navbar-item fp-profile-button">
{/* show the login button if user is anon */}
{/* show the profile button if the user is authed */}
{
(isProfileButton) ? (
<Link className="button" href="/profile">
<FontAwesomeIcon
icon={faUser}
className="fas fa-user-large mr-1"
></FontAwesomeIcon>
<span>{ authData?.user?.username || 'profile' }</span>
</Link>
) : (
<LoginButton></LoginButton>
)
}
</div>
</div>
</div>
</nav>
</>
)
}

View File

@ -0,0 +1,13 @@
'use client';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
export default function NotificationCenter() {
return (
<ToastContainer
position="top-right"
/>
)
}

View File

@ -0,0 +1,10 @@
export function DangerNotification ({ errors }: { errors: String[] }): JSX.Element {
return (
<div className="notification is-danger">
{errors && errors.map((error, index) => (
<p key={index}>Error:{error}</p>
))}
</div>
);
}

View File

@ -0,0 +1,82 @@
import Link from 'next/link';
interface IPagerProps {
baseUrl: string; // Pass the base URL as a prop
page: number;
pageCount: number;
}
export default function Pager({ baseUrl, page, pageCount }: IPagerProps): React.JSX.Element {
const pageNumbers = Array.from({ length: pageCount }, (_, i) => i + 1);
const getPagePath = (page: any) => {
const pageNumber = parseInt(page);
return `${baseUrl}/${pageNumber}`;
};
// Define the number of page links to show around the current page
const maxPageLinksToShow = 3;
// Calculate the range of page numbers to display
const startPage = Math.max(1, page - Math.floor(maxPageLinksToShow / 2));
const endPage = Math.min(pageCount, startPage + maxPageLinksToShow - 1);
return (
<div className="box">
<nav className="pagination">
{page > 1 && (
<Link href={getPagePath(page - 1)} className="pagination-previous">
<span>Previous</span>
</Link>
)}
{page < pageCount && (
<Link href={getPagePath(page + 1)} className="pagination-next" >
<span>Next</span>
</Link>
)}
<ul className="pagination-list">
{startPage > 1 && (
<li>
<Link href={getPagePath(1)} className={`pagination-link ${1 === page ? 'is-current' : ''}`}>
<span>1</span>
</Link>
</li>
)}
{startPage > 2 && (
<li>
<span className="pagination-ellipsis">&hellip;</span>
</li>
)}
{pageNumbers.slice(startPage - 1, endPage).map((pageNumber) => (
<li key={pageNumber}>
<Link href={getPagePath(pageNumber)} className={`pagination-link ${pageNumber === page ? 'is-current' : ''}`}>
<span>
{pageNumber}
</span>
</Link>
</li>
))}
{endPage < pageCount - 1 && (
<li>
<span className="pagination-ellipsis">&hellip;</span>
</li>
)}
{endPage !== pageCount && (
<li>
<Link href={getPagePath(pageCount)} className={`pagination-link ${pageCount === page ? 'is-current' : ''}`}>
<span>
{pageCount}
</span>
</Link>
</li>
)}
</ul>
</nav>
</div>
);
}

View File

@ -0,0 +1,55 @@
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
import { getPatrons } from '../lib/patreon';
import Link from 'next/link'
interface PatronsListProps {
displayStyle: string;
}
export default async function PatronsList({ displayStyle }: PatronsListProps) {
const patrons = await getPatrons()
if (!patrons) return (
<SkeletonTheme baseColor="#000" highlightColor="#000">
<Skeleton count={3} enableAnimation={false} />
</SkeletonTheme>
);
if (displayStyle === 'box') {
return (
<div className="columns is-multiline">
{patrons.map((patron) => (
<div key={patron.username} className="column is-full-mobile is-half-tablet is-one-third-desktop">
<div className="box">
<article className="media">
<div className="media-content">
<div className="content">
{patron.username && (
<span>
<b>{patron.username}</b>
</span>
)}
{patron.vanityLink && (
<Link target="_blank" href={patron.vanityLink}>
<span>{patron.vanityLink}</span>
<span className="icon">
<i className="fas fa-external-link-alt"></i>
</span>
</Link>
)}
</div>
</div>
</article>
</div>
</div>
))}
</div>
);
} else if (displayStyle === 'list') {
const patronNames = patrons.map((patron) => patron.username.trim()).join(', ');
return <span>{patronNames}</span>;
} else {
return <span></span>; // Handle unsupported display styles or provide a default display style
}
}

View File

@ -0,0 +1,70 @@
'use client'
import React, { useState } from 'react';
import { ITag } from '../lib/tags';
import Link from 'next/link';
import slugify from 'slugify';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFilter } from "@fortawesome/free-solid-svg-icons";
interface ISortableTagsProps {
tags: ITag[];
}
export default function SortableTags({ tags }: ISortableTagsProps) {
const [filterText, setFilterText] = useState('');
const [sortOption, setSortOption] = useState('Sort');
const filteredTags = tags.filter((tag: ITag) =>
tag.attributes.name.toLowerCase().includes(filterText.toLowerCase())
);
const sortedTags = [...filteredTags].sort((a, b) => {
if (sortOption === 'Alphabetical') {
return a.attributes.name.localeCompare(b.attributes.name);
} else if (sortOption === 'Frequency') {
return b.attributes.count - a.attributes.count;
}
return 0;
});
return (
<>
<div className="field is-grouped">
<div className="control has-icons-left">
<input
className="input"
type="text"
placeholder="Filter"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<span className="icon is-small is-left">
<FontAwesomeIcon icon={faFilter} className="fas fa-filter" />
</span>
</div>
<div className="control">
<div className="select">
<select
value={sortOption}
onChange={(e) => setSortOption(e.target.value)}
>
<option>Sort</option>
<option>Alphabetical</option>
<option>Frequency</option>
</select>
</div>
</div>
</div>
<div className="tags">
{sortedTags.map((tag: ITag) => (
<span key={tag.id} className="tag">
<Link href={`/tags/${slugify(tag.attributes.name)}`} className="vod-tag">
{tag.attributes.name} ({tag.attributes.count})
</Link>
</span>
))}
</div>
</>
);
}

View File

@ -0,0 +1,19 @@
import { IStream } from "@/lib/streams";
import Link from "next/link"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendar } from "@fortawesome/free-solid-svg-icons";
export function StreamButton({ stream }: { stream: IStream }) {
if (!stream) return <></>
// return <p>{JSON.stringify(stream, null, 2)}</p>
// return <span className="button is-small">{new Date(stream.attributes.date).toLocaleDateString()}</span>
return (
<Link
href={`/streams/${stream.attributes.cuid}`}
className="button is-medium"
>
<span className="mr-2"><FontAwesomeIcon icon={faCalendar} className="fas fa-calendar" /></span><span>{new Date(stream.attributes.date).toLocaleDateString()}</span>
</Link>
)
}

View File

@ -0,0 +1,187 @@
'use client';
import { IStream } from "@/lib/streams";
import NotFound from "app/streams/[cuid]/not-found";
import { IVod } from "@/lib/vods";
import Link from "next/link";
import Image from "next/image";
import { LocalizedDate } from "./localized-date";
import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
import { faTriangleExclamation, faCircleInfo, faThumbsUp, IconDefinition, faO, faX, faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { Hemisphere, Moon } from "lunarphase-js";
import { useEffect, useState } from "react";
import { faXTwitter } from "@fortawesome/free-brands-svg-icons";
export interface IStreamProps {
stream: IStream;
}
type Status = 'missing' | 'issue' | 'good';
interface StyleDef {
heading: string;
icon: IconDefinition;
desc1: string;
desc2: string;
}
function capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function hasNote(vod: IVod) {
if (!!vod?.attributes?.note) return true;
else return false;
}
function determineStatus(stream: IStream): Status {
if (stream.attributes.vods.data.length < 1) {
return 'missing'
} else {
if (stream.attributes.vods.data.some(vod => !hasNote(vod))) {
return 'good';
} else {
return 'issue';
}
}
}
export default function StreamPage({ stream }: IStreamProps) {
const displayName = stream.attributes.vtuber.data.attributes.displayName;
const date = new Date(stream.attributes.date);
const [hemisphere, setHemisphere] = useState(Hemisphere.NORTHERN);
const [selectedStatus, setSelectedStatus] = useState<Status>(determineStatus(stream));
const styleMap: Record<Status, StyleDef> = {
'missing': {
heading: 'is-danger',
icon: faTriangleExclamation,
desc1: "We don't have a VOD for this stream.",
desc2: 'Know someone who does?'
},
'issue': {
heading: 'is-warning',
icon: faCircleInfo,
desc1: "We have a VOD for this stream, but it's not full quality.",
desc2: 'Have a better copy?'
},
'good': {
heading: 'is-success',
icon: faThumbsUp,
desc1: "We have a VOD for this stream, and we think it's the best quality possible.",
desc2: "Have one that's even better?"
}
};
const { heading, icon, desc1, desc2 } = styleMap[selectedStatus] || {};
useEffect(() => {
const randomHemisphere = (Math.random() < 0.5 ? 0 : 1) ? Hemisphere.NORTHERN : Hemisphere.SOUTHERN;
setHemisphere(randomHemisphere);
}, []);
if (!stream) return <NotFound></NotFound>
// return <p>
// <pre>
// <code>
// {JSON.stringify(stream, null, 2)}
// </code>
// </pre>
// </p>
// const platformsList = '???';
const { isChaturbateInvite, isFanslyInvite } = stream.attributes.tweet.data.attributes;
const platformsArray = [
isChaturbateInvite ? 'Chaturbate' : null,
isFanslyInvite ? 'Fansly' : null
].filter(Boolean);
const platformsList = platformsArray.length > 0 ? platformsArray.join(', ') : 'None';
return (
<>
<div className="content">
<div className="section">
<h1 className="title"><LocalizedDate date={date} /> {displayName} Stream Archive</h1>
</div>
<div className="section columns is-multiline">
<div className="column is-half">
<div className="box">
<h2 className="title is-3">Details</h2>
<div className="columns is-multiline">
<div className="column is-full">
<span><b>Announcement</b>&nbsp;<span><Link target="_blank" href={stream.attributes.tweet.data.attributes.url}><FontAwesomeIcon icon={faXTwitter}></FontAwesomeIcon><FontAwesomeIcon icon={faExternalLinkAlt}></FontAwesomeIcon></Link></span></span><br></br>
<span><b>Platform</b>&nbsp;</span><span>{platformsList}</span><br></br>
<span><b>UTC Datetime</b>&nbsp;</span><time dateTime={date.toISOString()}>{date.toISOString()}</time><br></br>
<span><b>Local Datetime</b>&nbsp;</span><span>{date.toLocaleDateString()} {date.toLocaleTimeString()}</span><br></br>
<span><b>Lunar Phase</b>&nbsp;</span><span>{Moon.lunarPhase(date)} {Moon.lunarPhaseEmoji(date, { hemisphere })}</span><br></br>
<br></br>
{/* <select className="mt-5"
value={selectedStatus}
onChange={e => setSelectedStatus(e.target.value as Status)}
>
<option>good</option>
<option>issue</option>
<option>missing</option>
</select> */}
</div>
</div>
</div>
</div>
<div className="column is-half">
<article className={`message ${heading}`}>
<div className="message-header">
<span>VOD {capitalizeFirstLetter(selectedStatus)}</span>
</div>
<div className="message-body has-text-centered">
<span className="title is-1"><FontAwesomeIcon icon={icon}></FontAwesomeIcon></span>
<p className="mt-3">{desc1}</p>
<p className="mt-5">{desc2}<br />
<Link href={`/upload?cuid=${stream.attributes.cuid}`}>Upload it here.</Link></p>
</div>
</article>
</div>
</div>
<div className="section">
<h1 className="title">VODs</h1>
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Upload Date</th>
{/* <th>Thumbnail</th>
<th>Duration</th> */}
<th>Tags</th>
<th>Timestamps</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{stream.attributes.vods.data.map((vod: IVod) => (
<tr key={vod.id}>
{/* <p>{JSON.stringify(vod, null, 2)}</p> */}
<td><Link href={`/vt/${stream.attributes.vtuber.data.attributes.slug}/vod/${vod.attributes.cuid}`}>{vod.attributes.cuid}</Link></td>
<td>{vod.attributes.publishedAt}</td>
{/* <td>{(!!vod?.attributes?.thumbnail?.data?.attributes?.cdnUrl) ? <Image alt="" src={vod.attributes.thumbnail.data.attributes.cdnUrl}></Image> : <FontAwesomeIcon icon={faX} />}</td>
<td>{(!!vod?.attributes?.duration) ? vod.attributes.duration : <FontAwesomeIcon icon={faX} />}</td> */}
<td>{vod.attributes.tagVodRelations.data.length}</td>
<td>{vod.attributes.timestamps.data.length}</td>
<td>{(!!vod.attributes.note) ? <FontAwesomeIcon icon={faO} /> : <FontAwesomeIcon icon={faX} />}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,86 @@
import { IStream } from "@/lib/streams";
import NotFound from "app/vt/[slug]/not-found";
import { LocalizedDate } from "./localized-date";
import Link from "next/link";
import ChaturbateIcon from "@/components/icons/chaturbate";
import FanslyIcon from "@/components/icons/fansly";
import Image from "next/image";
export interface IStreamProps {
stream: IStream;
}
export function Stream({ stream }: IStreamProps) {
if (!stream) return <NotFound></NotFound>
return (
<div className="box">
<pre>
<code>
{JSON.stringify(stream, null, 2)}
</code>
</pre>
{/* <h3 className="title is-3">Stream {stream.attributes.date}</h3> */}
</div>
)
}
export function StreamSummary ({ stream }: IStreamProps) {
if (!stream) return <NotFound></NotFound>
// return (
// <pre>
// <code>
// {JSON.stringify(stream, null, 2)}
// </code>
// </pre>
// )
const archiveStatus = stream.attributes.archiveStatus;
const archiveStatusClassName = (() => {
if (archiveStatus === 'missing') return 'is-danger';
if (archiveStatus === 'good') return 'is-success';
if (archiveStatus === 'issue') return 'is-warning';
})();
return (
<Link href={`/streams/${stream.attributes.cuid}`}>
<div className="columns">
{/* <pre>
<code>
{JSON.stringify(stream, null, 2)}
</code>
</pre> */}
<div className="column is-narrow">
<span className="icon image">
<Image
className='is-rounded'
src={stream.attributes.vtuber.data.attributes.image}
alt={stream.attributes.vtuber.data.attributes.displayName}
width={28}
height={18}
/>
</span>
</div>
<div className="column">
<span>{stream.attributes.vtuber.data.attributes.displayName}</span>
</div>
<div className="column">
<LocalizedDate date={new Date(stream.attributes.date)}/>
</div>
<div className="column">
{(stream.attributes.isChaturbateStream) && <ChaturbateIcon width={18} height={18} className='mr-2'></ChaturbateIcon>}
{(stream.attributes.isFanslyStream) && <FanslyIcon width={18}></FanslyIcon>}
</div>
<div className="column">
<div className={`tag ${archiveStatusClassName}`}>{stream.attributes.archiveStatus}</div>
</div>
<div className="column">
<div className=""></div>
</div>
</div>
</Link>
)
}

View File

@ -0,0 +1,83 @@
'use client';
import FullCalendar from "@fullcalendar/react";
import interactionPlugin, { DateClickArg } from '@fullcalendar/interaction';
import dayGridPlugin from '@fullcalendar/daygrid';
import multiMonthPlugin from '@fullcalendar/multimonth'
import { IStream } from "@/lib/streams";
import { useRouter } from 'next/navigation';
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
interface IStreamsCalendarProps {
missingStreams: IStream[];
issueStreams: IStream[];
goodStreams: IStream[];
}
interface IEvent {
cuid: string;
start: Date;
end?: Date;
title: string;
vtuber: string;
}
// function buildStreamPageUrlFromDate(date: Date) {
// // const cuid =
// return `/s/${safeDate}`;
// }
function handleEventClick(info: any, router: AppRouterInstance) {
var eventObj = info.event;
const { cuid } = eventObj._def.extendedProps;
router.push(`/streams/${cuid}`);
}
function convertStreamToEvent(stream: IStream): IEvent {
console.log(stream)
const displayName = stream.attributes.vtuber.data.attributes.displayName;
return {
cuid: stream.attributes.cuid,
start: new Date(stream.attributes.date),
title: `${displayName}`,
vtuber: displayName
}
}
export default function StreamsCalendar({ missingStreams, issueStreams, goodStreams }: IStreamsCalendarProps) {
const router = useRouter();
const eventSources = [
{
events: missingStreams.map(convertStreamToEvent),
color: 'red'
},
{
events: issueStreams.map(convertStreamToEvent),
color: 'yellow',
},
{
events: goodStreams.map(convertStreamToEvent),
color: 'green'
}
]
return (
<>
<FullCalendar
plugins={[
dayGridPlugin,
interactionPlugin
]}
editable={false}
eventSources={eventSources}
eventClick={(args) => {
handleEventClick(args, router);
}}
/>
</>
)
}

View File

@ -0,0 +1,61 @@
import React from 'react'
import Link from 'next/link';
import VodCard from './vod-card';
import { IVtuber } from '@/lib/vtubers';
import { IVod } from '@/lib/vods';
import { getVodTitle } from './vod-page';
import { notFound } from 'next/navigation';
import { IStream, getStreamsForVtuber, getAllStreams } from '@/lib/streams';
import { StreamSummary } from '@/components/stream';
interface IStreamsListProps {
vtubers: IVtuber[];
page: number;
pageSize: number;
}
interface IStreamsListHeadingProps {
slug: string;
displayName: string;
}
export function StreamsListHeading({ slug, displayName }: IStreamsListHeadingProps): React.JSX.Element {
return (
<div className='box'>
<h3 className='title'>
<Link href={`/vt/${slug}`}>{displayName}</Link> Streams
</h3>
</div>
)
}
export default async function StreamsList({ vtubers, page = 1, pageSize = 24 }: IStreamsListProps): Promise<React.JSX.Element> {
if (!vtubers) return <div>vtubers is not defined. vtubers:{JSON.stringify(vtubers, null, 2)}</div>
// const streams = await getStreamsForVtuber(vtubers[0].id);
const streams = await getAllStreams(['missing', 'issue', 'good']);
if (!streams) return notFound();
// @todo [ ] pagination
// @todo [ ] sortability
return (
<>
<h2 className='title is-2'>Stream Archive</h2>
<aside className="menu">
<ul className="menu-list">
{streams.length < 1 && <p className='section'><i>There are no streams</i></p>}
{streams.map((stream: IStream) => (
<li>
<StreamSummary stream={stream}/>
</li>
))}
</ul>
</aside>
</>
);
}

View File

@ -0,0 +1,8 @@
import { useState } from 'react';
export function TagButton ({ name, selectedTag, setSelectedTag }: { name: string, selectedTag: string | null, setSelectedTag: Function }) {
return (
<button onClick={() => (selectedTag === name) ? setSelectedTag('') : setSelectedTag(name)} className={`button is-small mr-2 mb-1 ${(selectedTag === name) ? 'is-info' : ''}`}>{name}</button>
)
}

View File

@ -0,0 +1,57 @@
'use client';
import { ITagVodRelation, ITagVodRelationsResponse } from "@/lib/tag-vod-relations"
import { isWithinInterval, subHours } from "date-fns";
import { faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AuthContext, IUseAuth } from "./auth";
import { useContext, useEffect, useState } from "react";
import { useRouter } from 'next/navigation';
import { strapiUrl } from "@/lib/constants";
export interface ITagParams {
tvr: ITagVodRelation;
}
function isCreatedByMeRecently(userId: number | undefined, tvr: ITagVodRelation) {
if (!userId) return false;
if (userId !== tvr.attributes.creatorId) return false;
const last24H: Interval = { start: subHours(new Date(), 24), end: new Date() };
if (!isWithinInterval(new Date(tvr.attributes.createdAt), last24H)) return false;
return true;
}
async function handleDelete(authContext: IUseAuth | null, tvr: ITagVodRelation): Promise<void> {
if (!authContext) return;
const { authData } = authContext;
const res = await fetch(`${strapiUrl}/api/tag-vod-relations/deleteMine/${tvr.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authData?.accessToken}`,
'Content-Type': 'application/json'
}
})
if (!res.ok) throw new Error(res.statusText)
}
export function Tag({ tvr }: ITagParams) {
const authContext = useContext(AuthContext);
const router = useRouter()
const [shouldRenderDeleteButton, setShouldRenderDeleteButton] = useState<boolean>(false);
useEffect(() => {
setShouldRenderDeleteButton(isCreatedByMeRecently(authContext?.authData?.user?.id, tvr));
}, [authContext?.authData?.user?.id, tvr]);
return (
<span className="tags mr-2 mb-0">
<span className="tag">{tvr.attributes.tag.data.attributes.name}</span>
{shouldRenderDeleteButton && <a onClick={
() => {
handleDelete(authContext, tvr); router.refresh()
}
} className="tag"><span className="icon is-small"><FontAwesomeIcon className="fas fa-trash" icon={faTrash}></FontAwesomeIcon></span></a>}
</span>
)
}

View File

@ -0,0 +1,240 @@
'use client';
import { useState, useCallback, useEffect, useContext } from 'react';
import { IVod } from '@/lib/vods';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus, faX, faTags } from "@fortawesome/free-solid-svg-icons";
import { formatTimestamp } from '@/lib/dates';
import { readOrCreateTagVodRelation } from '@/lib/tag-vod-relations';
import { readOrCreateTag } from '@/lib/tags';
import { useAuth } from './auth';
import { debounce } from 'lodash';
import { strapiUrl } from '@/lib/constants';
import { VideoContext } from './video-context';
import { useForm } from "react-hook-form";
import { ITimestamp, createTimestamp } from '@/lib/timestamps';
import { useRouter } from 'next/navigation';
import styles from '@/assets/styles/fp.module.css'
import qs from 'qs';
import { toast } from 'react-toastify';
import slugify from 'slugify';
interface ITaggerProps {
vod: IVod;
setTimestamps: Function;
}
export interface ITagSuggestion {
id: number;
name: string;
createdAt: string;
}
type FormData = {
tagName: string;
isTimestamp: boolean;
};
export function Tagger({ vod, setTimestamps }: ITaggerProps): React.JSX.Element {
const { register, setValue, setError, setFocus, handleSubmit, watch, clearErrors, formState: { errors } } = useForm<FormData>({
defaultValues: {
tagName: '',
isTimestamp: true
}
});
const [isEditor, setIsEditor] = useState<boolean>(false);
const [isAuthed, setIsAuthed] = useState<boolean>(false);
const [tagSuggestions, setTagSuggestions] = useState<ITagSuggestion[]>([]);
const { authData } = useAuth();
const { timeStamp, tvrs, setTvrs } = useContext(VideoContext);
const router = useRouter();
const request = debounce((value: string) => {
search(value);
}, 300);
const debounceRequest = useCallback((v: string) => request(v), [request]);
// Callback version of watch. It's your responsibility to unsubscribe when done.
useEffect(() => {
const subscription = watch((value, { name, type }) => {
const tagNameValue = value.tagName as string;
if (name === 'tagName' && type === 'change' && value.tagName !== '') debounceRequest(tagNameValue);
});
return () => subscription.unsubscribe();
}, [watch, debounceRequest]);
useEffect(() => {
if (isEditor) {
setFocus('tagName');
getRandomSuggestions();
}
}, [isEditor, setFocus]);
useEffect(() => {
if (authData?.accessToken) {
setIsAuthed(true);
}
}, [isAuthed]);
async function getRandomSuggestions() {
const res = await fetch(`${strapiUrl}/api/tag/random`);
const tags = await res.json();
setTagSuggestions(tags)
}
async function search(value: string) {
const query = qs.stringify(
{
filters: {
tags: {
publishedAt: {
$notNull: true
}
}
},
query: value
}
)
if (!value) return;
const res = await fetch(`${strapiUrl}/api/fuzzy-search/search?${query}`, {
headers: {
'Authorization': `Bearer ${authData?.accessToken}`
}
})
const json = await res.json()
if (!res.ok) {
toast('failed to get recomended tags', { type: 'error', theme: 'dark' });
} else {
setTagSuggestions(json.tags)
}
}
async function onError(errors: any) {
console.error('submit handler encoutnered an error');
console.error(errors);
toast('there was an error');
}
async function onSubmit(values: { tagName: string, isTimestamp: boolean }) {
if (!authData?.accessToken) {
toast('must be logged in', { type: 'error', theme: 'dark' });
return
}
try {
const tag = await readOrCreateTag(authData.accessToken, slugify(values.tagName));
if (!tag) throw new Error(`readOrCreateTag failed`);
const tvr = await readOrCreateTagVodRelation(authData.accessToken, tag.id, vod.id);
console.log(`now we check to see if we have a TVR`);
console.log(tvr)
if (values.isTimestamp) {
console.log(`user specified that we must create a timestamp`);
const timestamp = await createTimestamp(authData, tag.id, vod.id, timeStamp);
console.log(timestamp)
if (!timestamp) throw new Error(`failed to create timestamp`)
setTimestamps((prevTimestamps: ITimestamp[]) => [...prevTimestamps, timestamp]);
}
setValue('tagName', '');
router.refresh();
} catch (e) {
toast(`${e}`, { type: 'error', theme: 'dark' });
}
}
if (!isAuthed) {
return <></>
} else {
if (isEditor) {
return (
<div className='card mt-2' style={{ width: '100%' }}>
<header className='card-header'>
<p className='card-header-title'><FontAwesomeIcon className='mr-2' icon={faTags}></FontAwesomeIcon>Tagger</p>
<button onClick={() => {
setIsEditor(false);
setValue('tagName', '');
setTagSuggestions([]);
clearErrors();
}} className='card-header-icon'>
<span className='icon'>
<FontAwesomeIcon
icon={faX}
className="fas fa-x"
></FontAwesomeIcon>
</span>
</button>
</header>
<div className='card-content'>
<form onSubmit={handleSubmit(onSubmit, onError)}>
<div className='mb-2'>
<label htmlFor="name" className='heading'>Add a tag</label>
<input
required
className="input"
placeholder="cum"
autoComplete='off'
maxLength={256}
minLength={3}
type="text"
{...register('tagName')}
></input>
</div>
<div className='mb-2'>
<span className='heading'>Suggestions</span>
{tagSuggestions.length > 0 && tagSuggestions.map((tag: ITagSuggestion) => (<button type="button" key={tag.id} className='button is-small is-rounded mr-1 is-success mb-1' onClick={() => setValue('tagName', tag.name)}>{tag.name}</button>))}
</div>
<div className={`mb-2 bulma-unselectable-mixin`}>
<label className={`checkbox ${styles.noselect}`}>
<input
className="mr-1"
type="checkbox"
{...register('isTimestamp')}
></input>
Timestamp {formatTimestamp(timeStamp)}
</label>
</div>
<div>
{(!!errors?.root?.serverError) && <div className="notification is-danger">{errors.root.serverError.message}</div>}
<input
className='button is-primary'
type="submit"
value={`Create Tag${(watch('isTimestamp')) ? ' & Timestamp': ''}`}
>
</input>
</div>
</form>
</div>
</div>
)
} else {
return (
<button className={`button is-small is-success ${styles.tagButton}`} onClick={() => setIsEditor(true)}>
<FontAwesomeIcon
icon={faPlus}
className="fab fa-plus mr-1"
></FontAwesomeIcon>
<span>Add a Tag</span>
</button>
);
}
}
}

View File

@ -0,0 +1,72 @@
import React, { useContext, useState, useEffect } from "react";
import { IVod } from "@/lib/vods";
import {
ITimestamp,
deleteTimestamp
} from "@/lib/timestamps";
import {
formatTimestamp,
formatUrlTimestamp,
} from "@/lib/dates";
import Link from 'next/link';
import { faClock, faLink, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AuthContext, IAuthData } from "./auth";
import { isWithinInterval, subHours, Interval } from 'date-fns';
import { useRouter } from 'next/navigation';
export interface ITimestampsProps {
vod: IVod;
timestamps: ITimestamp[];
setTimestamps: Function;
}
function isCreatedByMeRecently(authData: IAuthData, ts: ITimestamp) {
if (!authData?.user) return false;
if (authData.user.id !== ts.attributes.creatorId) return false;
const last24H: Interval = { start: subHours(new Date(), 24), end: new Date() };
return isWithinInterval(new Date(ts.attributes.createdAt), last24H);
}
export function TimestampsList({ vod, timestamps, setTimestamps }: ITimestampsProps): React.JSX.Element {
// const throttledTimestampFetch = throttle(getRawTimestampsForVod);
const authContext = useContext(AuthContext);
const hasTimestamps = timestamps.length > 0;
return (
<div className="timestamps mb-5">
{hasTimestamps && (
timestamps.map((ts: ITimestamp) => (
<p key={ts.id}>
{/* {JSON.stringify(ts, null, 2)}<br/><br/><br/> */}
<Link
href={`?t=${formatUrlTimestamp(ts.attributes.time)}`}
>
{formatTimestamp(ts.attributes.time)}
</Link>{' '}
<span className="mr-2">{ts.attributes.tag.data.attributes.name}</span>
{authContext?.authData && isCreatedByMeRecently(authContext.authData, ts) && (
<button
onClick={() => {
if (!authContext?.authData) return;
deleteTimestamp(authContext.authData, ts.id);
setTimestamps((prevTimestamps: ITimestamp[]) => prevTimestamps.filter((timestamp) => timestamp.id !== ts.id));
}}
className={`button icon`}
>
<FontAwesomeIcon icon={faTrash}></FontAwesomeIcon>
</button>
)}
</p>
))
)}
{!hasTimestamps && <div className="ml-5"><p><i>This VOD has no timestamps</i></p></div>}
</div>
);
}

View File

@ -0,0 +1,81 @@
import React from 'react';
import { IToy, IToysResponse } from '@/lib/toys';
import { IVtuber } from '@/lib/vtubers';
import Link from 'next/link';
import Image from 'next/image';
export interface IToyProps {
toy: IToy;
}
export interface IToysListsProps {
vtuber: IVtuber;
toys: IToysResponse;
page: number;
pageSize: number;
}
// interface VodsListProps {
// vtuber: IVtuber;
// vods: IVods;
// page: number;
// pageSize: number;
// }
export function ToysListHeading({ slug, displayName }: { slug: string, displayName: string }): React.JSX.Element {
return (
<div className='box'>
<h3 className='title'>
<Link href={`/vt/${slug}`}>{displayName}&apos;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.attributes.make} ${toy.attributes.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.attributes.linkTag.data.attributes.name}`}>
<figure style={{ position: 'relative', width: '100px', height: '100px' }}>
<Image
src={toy.attributes.image2}
alt={displayName}
objectFit='contain'
fill
/>
</figure>
<p className="heading">{toy.attributes.model}</p>
</Link>
</div>
);
};
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> */}
<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>
</div>
)
};

View File

@ -0,0 +1,327 @@
'use client';
import { IVtuber } from "@/lib/vtubers";
import { useSearchParams } from 'next/navigation';
import React, { useContext, useState, useEffect } from 'react';
import { UppyContext } from 'app/uppy';
import { LoginButton, useAuth } from '@/components/auth';
import { Dashboard } from '@uppy/react';
import styles from '@/assets/styles/fp.module.css'
import { projektMelodyEpoch } from "@/lib/constants";
import add from "date-fns/add";
import sub from "date-fns/sub";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheckCircle, faPaperPlane, faXmark } from "@fortawesome/free-solid-svg-icons";
import { useForm, useFieldArray, ValidationMode } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';
interface IUploadFormProps {
vtubers: IVtuber[];
}
interface IValidationResults {
valid: boolean;
issues: string[] | null;
}
interface IFormSchema extends Yup.InferType<typeof validationSchema> { };
const validationSchema = Yup.object().shape({
vtuber: Yup.number()
.required('VTuber is required'),
date: Yup.date()
.typeError('Invalid date') // https://stackoverflow.com/a/72985532/1004931
.min(sub(projektMelodyEpoch, { days: 1 }), 'Date must be after February 7 2020')
.max(add(new Date(), { days: 1 }), 'Date cannot be in the future')
.required('Date is required'),
notes: Yup.string().optional(),
attribution: Yup.boolean().optional(),
files: Yup.array()
.of(
Yup.object().shape({
key: Yup.string().required('key is required'),
uploadId: Yup.string().required('uploadId is required')
}),
)
.min(1, 'At least one file is required'),
});
export default function UploadForm({ vtubers }: IUploadFormProps) {
const searchParams = useSearchParams();
const cuid = searchParams.get('cuid');
const uppy = useContext(UppyContext);
const { authData } = useAuth();
const formOptions = {
resolver: yupResolver(validationSchema),
mode: 'onChange' as keyof ValidationMode,
};
const {
register,
handleSubmit,
formState: {
errors,
isValid
},
setValue,
watch,
} = useForm(formOptions);
const files = watch('files');
async function createUSC(data: IFormSchema) {
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/user-submitted-contents/createFromUppy`, {
method: 'POST',
headers: {
'authorization': `Bearer ${authData?.accessToken}`,
'content-type': 'application/json',
'accept': 'application/json'
},
body: JSON.stringify({
data: {
files: data.files,
attribution: data.attribution,
notes: data.notes,
vtuber: data.vtuber,
date: data.date
}
})
});
if (!res.ok) {
console.error('failed to fetch /api/user-submitted-contents/createFromUppy');
}
}
uppy.on('complete', async (result: any) => {
let files = result.successful.map((f: any) => ({ key: f.s3Multipart.key, uploadId: f.s3Multipart.uploadId }));
setValue('files', files);
});
return (
<>
<div className='section'>
<h2 className='title is-2'>Upload VOD</h2>
<p className="mb-5"><i>Together we can archive all lewdtuber livestreams!</i></p>
{(!authData?.accessToken)
?
<>
<aside className='notification is-danger'><p>Please log in to upload VODs</p></aside>
<LoginButton />
</>
: (
<div className='columns is-multiline'>
<form id="vod-details" onSubmit={handleSubmit((data) => createUSC(data))}>
<div className='column is-full'>
<section className="hero is-info mb-3">
<div className="hero-body">
<p className="title">
Step 1
</p>
<p className="subtitle">
Upload the file
</p>
</div>
</section>
<section className="section mb-5">
<Dashboard
uppy={uppy}
theme='dark'
proudlyDisplayPoweredByUppy={false}
/>
<input
required
hidden={true}
style={{ display: 'none' }}
className="input" type="text"
{...register('files')}
></input>
{errors.files && <p className="help is-danger">{errors.files.message?.toString()}</p>}
</section>
</div>
<div className='column is-full '>
{/* {(!cuid) && <aside className='notification is-info'>Hint: Some of these fields are filled out automatically when uploading from a <Link href="/streams">stream</Link> page.</aside>} */}
<section className="hero is-info mb-3">
<div className="hero-body">
<p className="title">
Step 2
</p>
<p className="subtitle">
Tell us about the VOD
</p>
</div>
</section>
<section className="section">
<div className="field">
<label className="label">VTuber</label>
<div className="select">
<select
required
// value={vtuber}
// onChange={(evt) => setVtuber(parseInt(evt.target.value))}
{...register('vtuber')}
>
{vtubers.map((vtuber: IVtuber) => (
<option value={vtuber.id}>{vtuber.attributes.displayName}</option>
))}
</select>
</div>
<p className="help is-info">Choose the VTuber this VOD belongs to. (More VTubers will be added when storage/bandwidth funding is secured.)</p>
{errors.vtuber && <p className="help is-danger">vtuber error</p>}
</div>
<div className="field">
<label className="label">Stream Date</label>
<input
required
className="input" type="date"
{...register('date')}
// onChange={(evt) => setDate(evt.target.value)}
></input>
<p className="help is-info">The date when the VOD was originally streamed.</p>
{errors.date && <p className="help is-danger">{errors.date.message?.toString()}</p>}
</div>
<div className="field">
<label className="label">Notes</label>
<textarea
className="textarea"
placeholder="e.g. Missing first 10 minutes of stream"
// onChange={(evt) => setNote(evt.target.value)}
{...register('notes')}
></textarea>
<p className="help is-info">If there are any issues with the VOD, put a note here. If there are no VOD issues, leave this field blank.</p>
</div>
<div className="field">
<label className="label">Attribution</label>
<label className="checkbox">
<input
type="checkbox"
// onChange={(evt) => setAttribution(evt.target.checked)}
{...register('attribution')}
/>
<span className={`ml-2 ${styles.noselect}`}>Credit {authData.user?.username} for the upload.</span>
<p className="help is-info">Check this box if you want your username displayed on the website. Thank you for uploading!</p>
</label>
</div>
</section>
</div>
<div className="column is-full">
<section className="hero is-info">
<div className="hero-body">
<p className="title">
Step 3
</p>
<p className="subtitle">
Send the form
</p>
</div>
</section>
<section className="section">
<div className="icon-text">
<span className={`icon has-text-${(files) ? 'success' : 'danger'}`}>
<FontAwesomeIcon icon={(files) ? faCheckCircle : faXmark}></FontAwesomeIcon>
</span>
<span>Step 1, File Upload</span>
</div>
<div className="icon-text">
<span className={`icon has-text-${(isValid) ? 'success' : 'danger'}`}>
<FontAwesomeIcon icon={(isValid) ? faCheckCircle : faXmark}></FontAwesomeIcon>
</span>
<span>Step 2, Metadata</span>
</div>
{/* <ErrorMessage
errors={errors}
name="date"
render={({ message }) => <p>{message}</p>}
/> */}
{/* {fields.map((field, index) => (
<div key={field.id}>
<input
{...register(
// @ts-expect-error incorrect schema resolution in library types
`guests.${index}.name`
)}
/>{' '}
<button onClick={() => remove(index)}>Remove</button>
</div>
))} */}
{/* {
JSON.stringify({
touchedFields: Object.keys(touchedFields),
errors: Object.keys(errors)
}, null, 2)
} */}
{/* setError('date', { type: 'custom', message: 'custom message' }); */}
<button disabled={!isValid} className="button is-primary is-large mt-5">
<span className="icon is-small">
<FontAwesomeIcon icon={faPaperPlane}></FontAwesomeIcon>
</span>
<span>Send</span>
</button>
</section>
</div>
</form>
</div>
)
}
</div>
</>
)
}

View File

@ -0,0 +1,229 @@
'use client';
import React, { useState } from 'react';
import { LogoutButton, useAuth } from "../components/auth"
import { patreonQuantumSupporterId, strapiUrl } from '../lib/constants';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSave, faTimes, faCheck } from "@fortawesome/free-solid-svg-icons";
import Skeleton from 'react-loading-skeleton';
interface IArchiveSupporterProps {
isNamePublic: boolean;
setIsNamePublic: Function;
}
interface ISaveButtonProps {
isDirty: boolean;
isLoading: boolean;
isSuccess: boolean;
isNamePublic: boolean;
isLinkPublic: boolean;
vanityLink: string;
setVanityLink: Function;
setIsLoading: Function;
setIsSuccess: Function;
setIsDirty: Function;
setAuthData: Function;
errors: String[];
setErrors: Function;
}
interface IQuantumSupporterProps {
isLinkPublic: boolean;
hasUrlBenefit: boolean;
setIsLinkPublic: Function;
vanityLink: string;
setVanityLink: Function;
}
export default function UserControls() {
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [isNamePublic, setIsNamePublic] = useState(false);
const [isLinkPublic, setIsLinkPublic] = useState(false);
const [errors, setErrors] = useState([])
const [vanityLink, setVanityLink] = useState('')
const { authData, setAuthData } = useAuth()
if (!authData) return <p>Loading...</p>
const hasUrlBenefit = (authData?.user?.patreonBenefits) ? authData.user.patreonBenefits.split(' ').includes(patreonQuantumSupporterId) : false;
return (
<div>
<section className="mb-5">
<h3 className="heading">Patron Perks</h3>
<Thanks />
<ArchiveSupporterPerks
isNamePublic={isNamePublic}
setIsNamePublic={setIsNamePublic}
/>
<QuantumSupporterPerks
isLinkPublic={isLinkPublic}
vanityLink={vanityLink}
setVanityLink={setVanityLink}
setIsLinkPublic={setIsLinkPublic}
hasUrlBenefit={hasUrlBenefit}
/>
<LogoutButton />
<span className='mr-1'></span>
<SaveButton
isSuccess={isSuccess}
isLoading={!authData}
isDirty={isDirty}
setIsSuccess={setIsSuccess}
setIsLoading={setIsLoading}
setIsDirty={setIsDirty}
isNamePublic={isNamePublic}
isLinkPublic={isLinkPublic}
vanityLink={vanityLink}
setVanityLink={setVanityLink}
setAuthData={setAuthData}
errors={errors}
setErrors={setErrors}
/>
</section>
</div>
);
};
export function SaveButton({
isDirty,
setIsDirty,
isLoading,
setIsLoading,
setIsSuccess,
isSuccess,
isNamePublic,
isLinkPublic,
vanityLink,
setVanityLink,
setAuthData,
errors,
setErrors,
}: ISaveButtonProps) {
const { authData } = useAuth();
const handleClick = async () => {
if (!authData?.user) return;
try {
setIsLoading(true);
const response = await fetch(`${strapiUrl}/api/profile/${authData.user.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authData.accessToken}`
},
body: JSON.stringify({
isNamePublic,
isLinkPublic,
vanityLink
})
});
setIsLoading(false);
setIsDirty(true);
if (!response.ok) {
setIsSuccess(false);
} else {
setIsSuccess(true);
// Update authData if needed
const updatedAuthData = { ...authData };
if (!updatedAuthData?.user) return;
updatedAuthData.user.vanityLink = vanityLink;
updatedAuthData.user.isNamePublic = isNamePublic;
updatedAuthData.user.isLinkPublic = isLinkPublic;
setAuthData(updatedAuthData);
}
} catch (error) {
if (error instanceof Error) {
setErrors(errors.concat([error.message]))
}
}
};
return (
<button
className={`button is-primary ${isLoading ? 'is-loading' : ''} ${isSuccess ? 'is-success' : isDirty && !isSuccess ? 'is-warning' : ''}`}
onClick={handleClick}
>
<span style={{ display: !isDirty ? 'inline' : 'none' }} className="icon">
<FontAwesomeIcon icon={faSave} />
</span>
<span style={{ display: isDirty && isSuccess ? 'inline' : 'none' }} className="icon">
<FontAwesomeIcon icon={faCheck} />
</span>
<span style={{ display: isDirty && !isSuccess ? 'inline' : 'none' }} className="icon">
<FontAwesomeIcon icon={faTimes} />
</span>
<span>Save</span>
</button>
)
}
export function Thanks() {
return <p className='mb-3'>Thank you so much for supporting Futureporn!</p>
}
export function QuantumSupporterPerks({ isLinkPublic, setIsLinkPublic, setVanityLink, vanityLink, hasUrlBenefit }: IQuantumSupporterProps) {
const { authData } = useAuth()
return (
<div className="field box" style={{ display: hasUrlBenefit ? 'block' : 'none' }}>
<label className="label">URL</label>
<div className="control">
<label className="checkbox noselect">
<span className='mr-1'><input
type="checkbox"
checked={isLinkPublic}
onChange={() => setIsLinkPublic(!isLinkPublic)}
/></span>
<span>Publicly display my URL <b>{vanityLink}</b> on the patrons page.</span>
</label>
</div>
<div className="control">
<input
className="input"
type="text"
placeholder="https://twitter.com/example"
value={vanityLink}
onChange={(e) => setVanityLink(e.target.value)}
/>
</div>
</div>
)
}
export function AdvancedArchiveSupporterPerks() {
}
export function ArchiveSupporterPerks({ isNamePublic, setIsNamePublic }: IArchiveSupporterProps) {
const { authData } = useAuth()
return (
<div className="field box fp-noselect">
<label className="label">Username</label>
<div className="control">
<label className="checkbox noselect">
<span className='mr-1'><input
type="checkbox"
checked={isNamePublic}
onChange={() => setIsNamePublic(!isNamePublic)}
/></span>
Publicly display <b>{(authData?.user?.username) ? authData.user.username : <Skeleton></Skeleton> }</b> on the patrons page.
</label>
</div>
</div>
)
}

View File

@ -0,0 +1,56 @@
import VideoApiElement from "@mux/mux-player/dist/types/video-api";
import { MutableRefObject, createContext, useState } from "react";
import { ITagVodRelation } from "@/lib/tag-vod-relations";
export interface IVideoContextValue {
timeStamp: number;
setTimeStamp: Function;
tvrs: ITagVodRelation[];
setTvrs: Function;
}
// const defaultContextValue = {
// timeStamp: 3,
// setTimeStamp: () => null,
// ref: null,
// }
export const VideoContext = createContext<IVideoContextValue>({} as IVideoContextValue);
// export function VideoContextProvider({ children }: IAuthContextProps): React.JSX.Element {
// const { value: authData, set: setAuthData } = useLocalStorageValue<IAuthData | null>('authData', {
// defaultValue: null,
// });
// const { value: lastVisitedPath, set: setLastVisitedPath } = useLocalStorageValue<string>('lastVisitedPath', {
// defaultValue: '/profile',
// initializeWithValue: false,
// });
// const router = useRouter();
// const login = async () => {
// const currentPath = window.location.pathname;
// setLastVisitedPath(currentPath);
// router.push(`${strapiUrl}/api/connect/patreon`);
// };
// const logout = () => {
// setAuthData({ accessToken: null, user: null });
// };
// return (
// <AuthContext.Provider
// value={{
// authData,
// setAuthData,
// lastVisitedPath,
// login,
// logout,
// }}
// >
// {children}
// </AuthContext.Provider>
// );
// }

View File

@ -0,0 +1,134 @@
'use client';
import { IVod } from "@/lib/vods";
import { useRef, useState, useEffect, useCallback } from "react";
import { VideoPlayer } from "./video-player";
import { Tagger } from './tagger';
import { ITimestamp, getTimestampsForVod } from "@/lib/timestamps";
import { TimestampsList } from "./timestamps-list";
import { ITagVodRelation } from "@/lib/tag-vod-relations";
import { VideoContext } from "./video-context";
import { getVodTitle } from "./vod-page";
import { useSearchParams } from 'next/navigation';
import VideoApiElement from "@mux/mux-player/dist/types/video-api";
import { parseUrlTimestamp } from "@/lib/dates";
import { faTags, faNoteSticky, faClock } from "@fortawesome/free-solid-svg-icons";
import { Tag } from './tag';
import VodNav from './vod-nav';
import LinkableHeading from "./linkable-heading";
export interface IVideoInteractiveProps {
vod: IVod;
}
function secondsToHumanReadable(timestampInSeconds: number): string {
const hours = Math.floor(timestampInSeconds / 3600);
const minutes = Math.floor((timestampInSeconds % 3600) / 60);
const seconds = timestampInSeconds % 60;
return `${hours}h${minutes}m${seconds}s`;
}
function humanReadableTimestampToSeconds(timestamp: string): number | null {
const parts = timestamp.split(':');
if (parts.length !== 3) {
// Invalid format, return null or throw an error as appropriate
return null;
}
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parseInt(parts[2], 10);
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
// Invalid numeric values, return null or throw an error as appropriate
return null;
}
const totalSeconds = hours * 3600 + minutes * 60 + seconds;
return totalSeconds;
}
export function VideoInteractive({ vod }: IVideoInteractiveProps): React.JSX.Element {
const [timeStamp, setTimeStamp] = useState(0);
const [tvrs, setTvrs] = useState([]);
const [isPlayerReady, setIsPlayerReady] = useState(false);
const [timestamps, setTimestamps] = useState<ITimestamp[]>([]);
const [currentTsPage, setCurrentTsPage] = useState(1);
const getTimestampPage = useCallback(async (page: number) => {
const timestamps = await getTimestampsForVod(vod.id, page);
setTimestamps(timestamps);
}, [vod.id, setTimestamps]); // IGNORE TS LINTER! DO NOT PUT timestamps HERE! IT CAUSES SELF-DDOS!
const ref = useRef(null);
const searchParams = useSearchParams();
const t = searchParams.get('t');
useEffect(() => {
getTimestampPage(currentTsPage);
}, [vod.id, getTimestampPage, currentTsPage]);
useEffect(() => {
if (!t) return;
if (!ref?.current) return;
const videoRef = ref.current as VideoApiElement;
const seconds = parseUrlTimestamp(t)
if (seconds === null) return;
videoRef.currentTime = seconds;
}, [t, isPlayerReady, ref])
return (
<VideoContext.Provider value={{
timeStamp,
setTimeStamp,
tvrs,
setTvrs
}}>
<VideoPlayer
vod={vod}
ref={ref}
setIsPlayerReady={setIsPlayerReady}
></VideoPlayer>
<h3 className="subtitle is-3">
{getVodTitle(vod)}
</h3>
<VodNav vod={vod}></VodNav>
<div className='mb-3 fp-vod-data'>
{vod.attributes.note && (
<>
<LinkableHeading text="Notes" slug="notes" icon={faNoteSticky}></LinkableHeading>
<div className='notification'>{vod.attributes.note}</div>
</>
)}
<LinkableHeading text="Tags" slug="tags" icon={faTags}></LinkableHeading>
<div className="tags has-addons mb-5">
{vod.attributes.tagVodRelations.data.length === 0 && <div className="ml-5"><p><i>This vod has no tags</i></p></div>}
{vod.attributes.tagVodRelations.data.map((tvr: ITagVodRelation) => (
<Tag key={tvr.id} tvr={tvr}></Tag>
))}
<Tagger vod={vod} setTimestamps={setTimestamps}></Tagger>
</div>
<LinkableHeading text="Timestamps" slug="timestamps" icon={faClock}></LinkableHeading>
<TimestampsList timestamps={timestamps} setTimestamps={setTimestamps} vod={vod}></TimestampsList>
</div>
</VideoContext.Provider>
)
}

View File

@ -0,0 +1,151 @@
'use client';
import { useEffect, useState, forwardRef, useContext, Ref } from 'react';
import { IVod } from '@/lib/vods';
import "plyr-react/plyr.css";
import { useAuth } from '@/components/auth';
import { getVodTitle } from './vod-page';
import { VideoSourceSelector } from '@/components/video-source-selector'
import { buildIpfsUrl } from '@/lib/ipfs';
import { strapiUrl } from '@/lib/constants';
import MuxPlayer from '@mux/mux-player-react/lazy';
import { VideoContext } from './video-context';
import MuxPlayerElement from '@mux/mux-player';
import VideoApiElement from "@mux/mux-player/dist/types/video-api";
interface IPlayerProps {
vod: IVod;
setIsPlayerReady: Function;
}
interface ITokens {
playbackToken: string;
storyboardToken: string;
thumbnailToken: string;
}
async function getMuxPlaybackTokens(playbackId: string, jwt: string): Promise<ITokens> {
const res = await fetch(`${strapiUrl}/api/mux-asset/secure?id=${playbackId}`, {
headers: {
'Authorization': `Bearer ${jwt}`
}
})
const json = await res.json()
return {
playbackToken: json.playbackToken,
storyboardToken: json.storyboardToken,
thumbnailToken: json.thumbnailToken
}
}
function hexToRgba(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export const VideoPlayer = forwardRef(function VideoPlayer( props: IPlayerProps, ref: Ref<MuxPlayerElement> ): React.JSX.Element {
const { vod, setIsPlayerReady } = props
const title: string = getVodTitle(vod);
const { authData } = useAuth();
const [selectedVideoSource, setSelectedVideoSource] = useState('');
const [isEntitledToCDN, setIsEntitledToCDN] = useState(false);
const [hlsSource, setHlsSource] = useState<string>('');
const [isClient, setIsClient] = useState(false);
const [playbackId, setPlaybackId] = useState('');
const [src, setSrc] = useState('');
const [tokens, setTokens] = useState({});
const { setTimeStamp } = useContext(VideoContext);
useEffect(() => {
setIsClient(true);
const token = authData?.accessToken;
const playbackId = vod?.attributes.muxAsset?.data?.attributes?.playbackId;
if (token) setIsEntitledToCDN(true);
if (selectedVideoSource === 'Mux') {
if (!!token && !!playbackId) {
try {
getMuxPlaybackTokens(vod.attributes.muxAsset.data.attributes.playbackId, token)
.then((tokens) => {
setTokens({
playback: tokens.playbackToken,
storyboard: tokens.storyboardToken,
thumbnail: tokens.thumbnailToken
})
setHlsSource(vod.attributes.muxAsset.data.attributes.playbackId)
setPlaybackId(vod.attributes.muxAsset.data.attributes.playbackId)
});
}
catch (e) {
console.error(e)
}
}
} else if (selectedVideoSource === 'B2') {
if (!vod.attributes.videoSrcB2) return; // This shouldn't happen because videoSourceSelector won't choose B2 if there is no b2. This return is only for satisfying TS
setHlsSource(vod.attributes.videoSrcB2.data.attributes.cdnUrl);
setPlaybackId('');
setSrc(vod.attributes.videoSrcB2.data.attributes.cdnUrl);
} else if (selectedVideoSource === 'IPFSSource') {
setHlsSource('');
setPlaybackId('');
setSrc(buildIpfsUrl(vod.attributes.videoSrcHash))
} else if (selectedVideoSource === 'IPFS240') {
setHlsSource('');
setPlaybackId('');
setSrc(buildIpfsUrl(vod.attributes.video240Hash))
}
}, [selectedVideoSource, authData, vod, setHlsSource]);
if (!isClient) return <></>
return (
<>
<MuxPlayer
onCanPlay={() => {
setIsPlayerReady(true)}
}
ref={ref}
preload="auto"
crossOrigin="*"
loading="viewport"
playbackId={playbackId}
src={src}
tokens={tokens}
primaryColor="#FFFFFF"
secondaryColor={hexToRgba(vod.attributes.vtuber.data.attributes.themeColor, 0.85)}
metadata={{
video_title: getVodTitle(vod)
}}
streamType="on-demand"
onTimeUpdate={(evt) => {
const muxPlayer = evt.target as VideoApiElement
const { currentTime } = muxPlayer;
setTimeStamp(currentTime)
}}
muted
></MuxPlayer>
<VideoSourceSelector
isMux={!!vod?.attributes.muxAsset?.data?.attributes?.playbackId}
isB2={!!vod?.attributes.videoSrcB2?.data?.attributes?.cdnUrl}
isIPFSSource={!!vod?.attributes.videoSrcHash}
isIPFS240={!!vod?.attributes.video240Hash}
isEntitledToCDN={isEntitledToCDN}
selectedVideoSource={selectedVideoSource}
setSelectedVideoSource={setSelectedVideoSource}
></VideoSourceSelector>
</>
)
})

View File

@ -0,0 +1,130 @@
'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;
isIPFSSource: boolean;
isIPFS240: boolean;
isEntitledToCDN: boolean;
setSelectedVideoSource: (option: string) => void;
selectedVideoSource: string;
}
export function VideoSourceSelector({
isMux,
isB2,
isIPFSSource,
isIPFS240,
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 (isIPFSSource) {
return 'IPFSSource';
} else if (isIPFS240) {
return 'IPFS240';
}
// If no sources are available, return an empty string
return '';
};
// If selectedVideoSource is unset, find the value to use
if (selectedVideoSource === '') {
// 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, isIPFSSource, isIPFS240, isEntitledToCDN, selectedVideoSource, setSelectedVideoSource]);
// Handle button click to change the selected video source
const handleSourceClick = (source: string) => {
if (
(source === 'Mux' && isMux && isEntitledToCDN) ||
(source === 'B2' && isB2 && isEntitledToCDN) ||
(source === 'IPFSSource') ||
(source === 'IPFS240')
) {
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 && !isIPFSSource && !isIPFS240) && <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>}
{(isIPFSSource) && <div className="nav-item">
<button onClick={() => handleSourceClick('IPFSSource')} className={`button ${(selectedVideoSource === 'IPFSSource') && 'is-active'}`}>
<span className="icon">
<FontAwesomeIcon icon={faGlobe} className="fas fa-globe" />
</span>
<span>IPFS Src</span>
</button>
</div>}
{(isIPFS240) && <div className="nav-item">
<button onClick={() => handleSourceClick('IPFS240')} className={`button ${(selectedVideoSource === 'IPFS240') && 'is-active'}`}>
<span className="icon">
<FontAwesomeIcon icon={faGlobe} className="fas fa-globe" />
</span>
<span>IPFS 240p</span>
</button>
</div>}
</nav>
</div>
</>
)
}

View File

@ -0,0 +1,72 @@
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPatreon } from "@fortawesome/free-brands-svg-icons";
import { faVideo } from "@fortawesome/free-solid-svg-icons";
import { getSafeDate, getDateFromSafeDate } from '@/lib/dates';
import { IVtuber } from '@/lib/vtubers';
import Image from 'next/image'
import { LocalizedDate } from '@/components/localized-date'
import { IMuxAsset, IMuxAssetResponse } from "@/lib/types";
import { IB2File } from "@/lib/b2File";
interface IVodCardProps {
id: number;
title: string;
date: string;
muxAsset: string | undefined;
thumbnail: string | undefined;
vtuber: IVtuber;
}
export default function VodCard({id, title, date, muxAsset, thumbnail = 'https://futureporn-b2.b-cdn.net/default-thumbnail.webp', vtuber}: IVodCardProps) {
if (!vtuber?.attributes?.slug) return <div className="card"><p>VOD {id} is missing VTuber</p></div>
return (
<div key={id} className="column is-full-mobile is-one-third-tablet is-one-fourth-desktop is-one-fifth-fullhd">
<div className="card">
<Link href={`/vt/${vtuber.attributes.slug}/vod/${getSafeDate(date)}`}>
<div className="card-image">
<figure className="image is-16by9">
<Image
src={thumbnail}
alt={title}
placeholder="blur"
blurDataURL={vtuber.attributes.imageBlur}
fill={true}
style={{
objectFit: 'cover',
}}
/>
</figure>
</div>
<div className="card-content">
<h1>{title}</h1>
<LocalizedDate date={new Date(date)} />
<footer className="mt-3 card-footer">
<div className="card-footer-item">
<FontAwesomeIcon
icon={faVideo}
className="fas fa-video"
></FontAwesomeIcon>
</div>
{muxAsset && (
<div className="card-footer-item">
<FontAwesomeIcon icon={faPatreon} className="fab fa-patreon" />
</div>
)}
</footer>
</div>
</Link>
</div>
</div>
)
}

View File

@ -0,0 +1,90 @@
'use client';
import { faVideo, faExternalLinkAlt, faShareAlt } from "@fortawesome/free-solid-svg-icons";
import { faXTwitter } from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from 'next/image';
import Link from 'next/link';
import { IVod } from '@/lib/vods';
import { buildIpfsUrl } from '@/lib/ipfs';
import { getSafeDate } from "@/lib/dates";
import { StreamButton } from '@/components/stream-button';
import VtuberButton from "./vtuber-button";
export function getDownloadLink(cid: string, safeDate: string, slug: string, quality: string) {
return buildIpfsUrl(`${cid}?filename=${slug}-${safeDate}-${quality}.mp4`)
}
export interface IVodNavProps {
vod: IVod;
}
export default function VodNav ({ vod }: IVodNavProps) {
const safeDate = getSafeDate(vod.attributes.date2);
return (
<nav className='level'>
<div className='level-left'>
<div className="level-item">
<Link href={`/vt/${vod.attributes.vtuber.data.attributes.slug}`}>
<VtuberButton
size="medium"
image={vod.attributes.vtuber.data.attributes.image}
displayName={vod.attributes.vtuber.data.attributes.displayName}
></VtuberButton>
</Link>
</div>
<div className="level-item">
<StreamButton stream={vod.attributes.stream.data} />
</div>
</div>
<div className='level-right'>
{vod.attributes.videoSrcHash && (
<>
<div className='level-item'>
<Link
download={getDownloadLink(vod.attributes.videoSrcHash, safeDate, vod.attributes.vtuber.data.attributes.slug, 'source')}
className='button is-info is-small'
target="_blank"
prefetch={false}
href={getDownloadLink(vod.attributes.videoSrcHash, safeDate, vod.attributes.vtuber.data.attributes.slug, 'source')}
>
<FontAwesomeIcon icon={faVideo} className="fas fa-download mr-1" />
<span className='mr-1'>Source</span>
<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" />
</Link>
</div>
</>
)}
{vod.attributes.video240Hash && (
<div className='level-item'>
<span>
<Link
download={getDownloadLink(vod.attributes.video240Hash, safeDate, vod.attributes.vtuber.data.attributes.slug, '240p')}
className='button is-info is-small'
target="_blank"
prefetch={false}
href={getDownloadLink(vod.attributes.video240Hash, safeDate, vod.attributes.vtuber.data.attributes.slug, '240p')}
>
<FontAwesomeIcon icon={faVideo} className="fas fa-download mr-1" />
<span className='mr-1'>240p</span>
<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" />
</Link>
</span>
</div>
)}
{vod.attributes.announceUrl && (
<div className='level-item'>
<Link
target="_blank"
href={vod.attributes.announceUrl}
className="button is-small"
>
<span className="mr-2"><FontAwesomeIcon icon={faXTwitter} className="fab fa-x-twitter" /></span><span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></span>
</Link>
</div>
)}
</div>
</nav>
)
}

View File

@ -0,0 +1,96 @@
import { getUrl, getNextVod, getPreviousVod, getLocalizedDate } from '@/lib/vods';
import { IVod } from '@/lib/vods';
import Link from 'next/link';
import { VideoInteractive } from './video-interactive';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons";
import { notFound } from 'next/navigation';
import { IpfsCid } from './ipfs-cid';
import LinkableHeading from './linkable-heading';
export function getVodTitle(vod: IVod): string {
return vod.attributes.title || vod.attributes.announceTitle || `${vod.attributes.vtuber.data.attributes.displayName} ${vod.attributes.date2}`;
}
export function buildMuxUrl(playbackId: string, token: string) {
return `https://stream.mux.com/${playbackId}.m3u8?token=${token}`
}
export function buildMuxSignedPlaybackId(playbackId: string, token: string) {
return `${playbackId}?token=${token}`
}
export function buildMuxThumbnailUrl(playbackId: string, token: string) {
return `https://image.mux.com/${playbackId}/storyboard.vtt?token=${token}`
}
export default async function VodPage({vod}: { vod: IVod }) {
if (!vod) notFound();
const slug = vod.attributes.vtuber.data.attributes.slug;
const previousVod = await getPreviousVod(vod);
const nextVod = await getNextVod(vod);
return (
<div className="container">
<div className="block">
<div className="box">
<VideoInteractive vod={vod}></VideoInteractive>
{(vod.attributes.videoSrcHash || vod.attributes.video240Hash) && (
<>
<LinkableHeading text="IPFS Content IDs" slug="ipfs" icon={faGlobe}></LinkableHeading>
{vod.attributes.videoSrcHash && (
<IpfsCid label="Source" cid={vod.attributes.videoSrcHash}></IpfsCid>
)}
{vod.attributes.video240Hash && (
<IpfsCid label="240p" cid={vod.attributes.video240Hash}></IpfsCid>
)}
</>
)}
<nav className="level mt-5">
<div className='level-left'>
<div className='level-item'>
{!!previousVod && (
<Link className='button' href={getUrl(previousVod, slug, previousVod.attributes.date2)}>
<FontAwesomeIcon
icon={faChevronLeft}
className='fas faChevronLeft'
></FontAwesomeIcon>
<span className="ml-2">Prev VOD {getLocalizedDate(previousVod)}</span>
</Link>
)}
</div>
</div>
<div className='level-center'>
<div className='level-item'>
<p className='has-text-grey-darker'>UID {vod.attributes.cuid}</p>
</div>
</div>
<div className='level-right'>
<div className='level-item'>
{!!nextVod && (
<Link className='button' href={getUrl(nextVod, slug, nextVod.attributes.date2)}>
<span className="mr-2">Next VOD {getLocalizedDate(nextVod)}</span>
<FontAwesomeIcon
icon={faChevronRight}
className='fas faChevronRight'
></FontAwesomeIcon>
</Link>
)}
</div>
</div>
</nav>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import React from 'react'
import Link from 'next/link';
import VodCard from './vod-card';
import { IVtuber, IVtuberResponse } from '@/lib/vtubers';
import { IVodsResponse, IVod } from '@/lib/vods';
import { getVodTitle } from './vod-page';
import { notFound } from 'next/navigation';
interface IVodsListProps {
vtuber?: IVtuber;
vods: IVod[];
page: number;
pageSize: number;
}
interface IVodsListHeadingProps {
slug: string;
displayName: string;
}
export function VodsListHeading({ slug, displayName }: IVodsListHeadingProps): React.JSX.Element {
return (
<div className='box'>
<h3 className='title'>
<Link href={`/vt/${slug}`}>{displayName}</Link> Vods
</h3>
</div>
)
}
export default function VodsList({ vods, page = 1, pageSize = 24 }: IVodsListProps): React.JSX.Element {
// if (!vtuber) return <div>vtuber is not defined. vtuber:{JSON.stringify(vtuber, null, 2)}</div>
// if (!vods) return <div>failed to load vods</div>;
if (!vods) return notFound()
// @todo [x] pagination
// @todo [x] sortability
return (
<>
{/* <p>VodsList on page {page}, pageSize {pageSize}, with {vods.data.length} vods</p> */}
{/* <pre>
<code>
{JSON.stringify(vods.data, null, 2)}
</code>
</pre> */}
<div className="columns is-multiline is-mobile">
{vods.map((vod: IVod) => (
<VodCard
key={vod.id}
id={vod.id}
title={getVodTitle(vod)}
date={vod.attributes.date2}
muxAsset={vod.attributes.muxAsset?.data?.attributes.playbackId}
vtuber={vod.attributes.vtuber.data}
thumbnail={vod.attributes.thumbnail?.data?.attributes?.cdnUrl}
/>
))}
</div>
</>
);
}

View File

@ -0,0 +1,29 @@
import Image from "next/image"
interface VtuberButtonProps {
image: string;
displayName: string;
size?: string;
}
export default function VtuberButton ({ image, displayName, size }: VtuberButtonProps) {
const sizeClass = (() => {
if (size === 'large') return 'is-large';
if (size === 'medium') return 'is-medium';
if (size === 'small') return 'is-small'
})();
return (
<div className={`button ${sizeClass}`}>
<span className="icon image">
<Image
className='is-rounded'
src={image}
alt={displayName}
width={32}
height={32}
/>
</span>
<span>{displayName}</span>
</div>
);
}

View File

@ -0,0 +1,43 @@
import Link from "next/link";
import type { IVtuber } from '@/lib/vtubers';
import { getVodsForVtuber } from "@/lib/vods";
import Image from 'next/image'
import NotFound from "app/vt/[slug]/not-found";
import ArchiveProgress from "./archive-progress";
export default async function VTuberCard(vtuber: IVtuber) {
const { id, attributes: { slug, displayName, imageBlur, image }} = vtuber;
if (!imageBlur) return <p>this is a vtubercard with an invalid imageBlur={imageBlur}</p>
const vods = await getVodsForVtuber(id)
if (!vods) return <NotFound></NotFound>
return (
<Link
href={"/vt/"+slug}
className="column is-full-mobile is-half-tablet"
>
<div className="card">
<div className="card-content">
<div className="media">
<div className="media-left">
<figure className="image is-48x48">
<Image
className="is-rounded"
src={image}
alt={displayName}
placeholder="blur"
blurDataURL={imageBlur}
width={48}
height={48}
/>
</figure>
</div>
<div className="media-content">
<p className="title is-4 mb-3">{displayName}</p>
<ArchiveProgress vtuber={vtuber}/>
</div>
</div>
</div>
</div>
</Link>
)
}

View File

@ -0,0 +1,123 @@
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { strapiUrl } from '@/lib/constants'
import { useAuth, IAuthData, IUser, IJWT } from '@/components/auth'
import { DangerNotification } from '@/components/notifications'
export type AccessToken = string | null;
export default function Page() {
const searchParams = useSearchParams()
const router = useRouter()
const { authData, setAuthData, lastVisitedPath } = useAuth()
const [errors, setErrors] = useState<String[]>([])
const initAuth = async () => {
try {
const accessToken: AccessToken = getAccessTokenFromURL();
const json = await getJwt(accessToken);
if (!json) {
setErrors(errors.concat(['Unable to get access token from portal. Please try again later or check Futureporn Discord.']))
} else {
storeJwtJson(json)
redirect();
}
} catch (error) {
console.error(error);
}
};
const storeJwtJson = (json: IJWT) => {
// Store the JWT and other relevant data in your state management system
const data: IAuthData = {
accessToken: json.jwt,
user: json.user,
}
setAuthData(data);
}
const getAccessTokenFromURL = () => {
const accessToken: AccessToken = searchParams?.get('access_token');
if (!accessToken) {
throw new Error('Failed to get access_token from auth portal.');
}
return accessToken;
};
const getJwt = async (accessToken: AccessToken): Promise<IJWT | null> => {
try {
const response = await fetch(`${strapiUrl}/api/auth/patreon/callback?access_token=${accessToken}`);
if (!response.ok) {
// Handle non-2xx HTTP response status
throw new Error(`Failed to fetch. Status: ${response.status}`);
}
const json = await response.json();
if (!json.jwt) {
throw new Error('Failed to get auth token. Please try again later.');
}
return json;
} catch (error) {
console.error(error);
return null; // Return null or handle the error in an appropriate way
}
};
const redirect = () => {
if (!lastVisitedPath) return; // on first render, it's likely null
router.push(lastVisitedPath);
};
useEffect(() => {
initAuth()
})
{/*
After user auths,
they are redirected to this page.
This page grabs the access_token from the query string,
exchanges it with strapi for a jwt
then persists the jwt
After a jwt is stored, this page redirects the user
to whatever page they were previously on.
*/}
// @todo get query parameters
// @todo save account info to session
// @todo ???
// @todo profit
// const searchParams = useSearchParams()
// const accessToken = searchParams?.get('access_token');
// const refreshToken = searchParams?.get('refresh_token');
// const lastVisitedPath = '@todo!'
return (
<div className='box'>
{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

@ -0,0 +1,104 @@
import Link from 'next/link';
import { getVtuberBySlug } from '../lib/vtubers'
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { faLink } from '@fortawesome/free-solid-svg-icons';
import { projektMelodyEpoch } from '@/lib/constants';
import LinkableHeading from '@/components/linkable-heading';
export default async function Page() {
return (
<div className="content">
<div className="block">
<div className="box">
<p id="faq" className="title">Frequently Asked Questions (FAQ)</p>
<div className="section">
<LinkableHeading text="What is a VTuber?" slug="vtuber"></LinkableHeading>
<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">
<LinkableHeading text="What is a Lewdtuber?" slug="lewdtuber"></LinkableHeading>
<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">
<LinkableHeading text="What is IPFS?" slug="ipfs"></LinkableHeading>
<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, each with their own tradeoffs. Firstly, you can use a public gateway. IPFS public gateways can be overloaded and unreliable at times, but it&apos;s simple to use. All you have to do is visit a gateway URL containing the CID. One such example is <Link target="_blank" href="https://ipfs.io/ipfs/bafkreifdwhy2rnn26w5zieqxmowocxzbo7p5n7sy5u4fj7beymqoxungem"><span className='mr-1'>https://ipfs.io/ipfs/bafkreigaknpexyvxt76zgkitavbwx6ejgfheup5oybpm77f3pxzrvwpfdi</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> </p>
<p>The next way to use IPFS consists of 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. A local IPFS node runs for as long as IPFS Desktop is active, and you can query this node for the content you want. This setup works best 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>, or a web browser that natively 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>
</div>
<div className="section">
<div>
<div className="mb-3">
<LinkableHeading text="My browser says the video is not reachable" slug="not-working"></LinkableHeading>
</div>
<div>
<p>You may get an error when clicking on a video link. Errors such as <code>DNS_PROBE_FINISHED_NXDOMAIN</code></p>
<p>This is a DNS server error that occurs when a web browser isn&apos;t able to translate the domain name into an IP address.</p>
<p>If this happens, using a different DNS server can fix it. There are many gratis services to choose from, including <Link target="_blank" href="https://cloudflare-dns.com/dns/"><span className="mr-1">Cloudflare DNS</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> or <Link target="_blank" href="https://developers.google.com/speed/public-dns/"><span className="mr-1">Google DNS</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.</p>
<p>Often, using a DNS server other than the one provided to you by your ISP can improve your internet browsing experience for all websites.</p>
</div>
</div>
</div>
<div className="section">
<div>
<div className='mb-3'>
<LinkableHeading text="The IPFS videos are slow! I can't even watch it!" slug="slow"></LinkableHeading>
</div>
<div>
<p>Bandwidth is prohibitively expensive, so that&apos;s the free-to-play experience at the moment. (<Link href="/patrons">Patrons</Link> get access to CDN which is much faster.)</p>
<p>If the video isn&apos;t loading fast enough to stream, you can <Link href="#download">download</Link> the entire video then playback later on your device.</p>
</div>
</div>
</div>
<div className='section'>
<div>
<div className='mb-3'>
<LinkableHeading text="Can I download the video?" slug="download" />
<p>Yes! The recommended way is to use either <Link target="_blank" href="https://docs.ipfs.tech/install/ipfs-desktop/"><span className='mr-1'>IPFS Desktop</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link> or <Link target="_blank" href="https://dist.ipfs.tech/#ipget"><span className='mr-1'>ipget</span><FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.</p>
<p><b>ipget</b> example is as follows.</p>
<pre>
<code>
ipget --progress -o projektmelody-chaturbate-2023-12-03.mp4 bafybeiejms45zzonfe7ndr3mp4vmrqrg3btgmuche3xkeq5b77uauuaxkm
</code>
</pre>
</div>
</div>
</div>
<div className='section'>
<div className="mb-3">
<LinkableHeading text="There&apos;s a cool new Lewd Vtuber who streams on CB. Will you archive their vods?" slug="other-luber" />
</div>
<p>Yes. Futureporn aims to become the galaxy&apos;s best VTuber hentai site.</p>
</div>
<div className='section'>
<div className='mb-3'>
<LinkableHeading text="How can I help?" slug="how-can-i-help" />
<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>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

View File

@ -0,0 +1,11 @@
import { generateFeeds } from "@/lib/rss"
export async function GET() {
const feeds = await generateFeeds()
const options = {
headers: {
"Content-Type": "application/json"
}
}
return new Response(feeds.json1, options)
}

View File

@ -0,0 +1,11 @@
import { generateFeeds } from "@/lib/rss"
export async function GET() {
const { atom1 } = await generateFeeds()
const options = {
headers: {
"Content-Type": "application/atom+xml"
}
}
return new Response(atom1, options)
}

View File

@ -0,0 +1,28 @@
import Link from 'next/link'
export default async function Page() {
return (
<>
<div className="content">
<div className="block">
<div className="box">
<p className="title">RSS Feed</p>
<p className="subtitle">Keep up to date with new VODs using Real Simple Syndication (RSS).</p>
<p>Don&apos;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'><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>
</div>
</>
)
}

View File

@ -0,0 +1,11 @@
import { generateFeeds } from "@/lib/rss"
export async function GET() {
const { rss2 } = await generateFeeds()
const options = {
headers: {
"Content-Type": "application/rss+xml"
}
}
return new Response(rss2, options)
}

View File

@ -0,0 +1,75 @@
import { getGoals } from "@/lib/pm";
import { getCampaign } from "@/lib/patreon";
interface IFundingStatusBadgeProps {
completedPercentage: number;
}
function FundingStatusBadge({ completedPercentage }: IFundingStatusBadgeProps) {
if (completedPercentage === 100) return <span className="tag is-success">Funded</span>;
return (
<span className="tag is-warning">
<span className="mr-1">
{completedPercentage}% Funded
</span>
</span>
);
}
// export interface IGoals {
// complete: IIssue[];
// inProgress: IIssue[];
// planned: IIssue[];
// featuredFunded: IIssue;
// featuredUnfunded: IIssue;
// }
export default async function Page() {
const { pledgeSum } = await getCampaign()
const goals = await getGoals(pledgeSum);
if (!goals) return <p>failed to get goals</p>
const { inProgress, planned, complete } = goals;
return (
<>
<div className="content">
<div className="block">
<div className="box">
<h1 className="title">Goals</h1>
<p className="subtitle mt-5">
<span>In Progress</span>
</p>
<ul className="">
{inProgress.map((goal) => (
<li key={goal.id}>
{goal.title} {(!!goal?.amountCents && !!goal.completedPercentage) && <FundingStatusBadge completedPercentage={goal.completedPercentage} />}
</li>
))}
</ul>
<p className="subtitle mt-5">
<span>Planned</span>
</p>
<ul className="">
{planned.map((goal) => (
<li key={goal.id}>
{goal.title} {(!!goal?.amountCents && !!goal.completedPercentage) && <FundingStatusBadge completedPercentage={goal.completedPercentage} />}
</li>
))}
</ul>
<p className="subtitle mt-5">
<span>Completed</span>
</p>
<ul className="">
{complete.map((goal) => (
<li key={goal.id}>
{goal.title} {(!!goal?.amountCents && !!goal.completedPercentage) && <FundingStatusBadge completedPercentage={goal.completedPercentage} />}
</li>
))}
</ul>
</div>
</div>
</div>
</>
)
}

View File

@ -0,0 +1,12 @@
import Tes from '@/assets/svg/tes';
export default async function Page() {
return (
<div className="content">
<div className="box">
<h2>Healthy!</h2>
<Tes></Tes>
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
import VodsList from '@/components/vods-list';
import { getVods } from '@/lib/vods';
import Pager from '@/components/pager';
interface IPageParams {
params: {
page: number;
};
}
export default async function Page({ params: { page } }: IPageParams) {
let vods;
try {
vods = await getVods(page, 24, true);
} catch (error) {
console.error("An error occurred:", error);
return <div>Error: {JSON.stringify(error)}</div>;
}
return (
<>
<h2 className='title is-2'>Latest VODs</h2>
<p className='subtitle'>page {page}</p>
<VodsList vods={vods.data} page={page} pageSize={24} />
<Pager
baseUrl='/latest-vods'
page={page}
pageCount={vods.meta.pagination.pageCount}
/>
</>
);
}

View File

@ -0,0 +1,24 @@
import VodsList from '@/components/vods-list';
import { IVodsResponse } from '@/lib/vods';
import Pager from '@/components/pager';
import { getVods } from '@/lib/vods';
interface IPageParams {
params: {
slug: string;
}
}
export default async function Page({ params }: IPageParams) {
const vods: IVodsResponse = await getVods(1, 24);
return (
<>
<h2 className='title is-2'>Latest VODs</h2>
<p className='subtitle'>page 1</p>
<VodsList vods={vods.data} page={1} pageSize={24} />
<Pager baseUrl='/latest-vods' page={1} pageCount={vods.meta.pagination.pageCount} />
</>
)
}

View File

@ -0,0 +1,70 @@
import { ReactNode } from 'react'
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 NotificationCenter from './components/notification-center';
import UppyProvider from './uppy';
// import NextTopLoader from 'nextjs-toploader';
// import Ipfs from './components/ipfs'; // slows down the page too much
export const metadata: Metadata = {
title: 'Futureporn.net',
description: "The Galaxy's Best VTuber Hentai Site",
other: {
RATING: 'RTA-5042-1996-1400-1577-RTA'
},
metadataBase: new URL('https://futureporn.net'),
twitter: {
site: '@futureporn_net',
creator: '@cj_clippy'
},
alternates: {
types: {
'application/atom+xml': '/feed/feed.xml',
'application/rss+xml': '/feed/rss.xml',
'application/json': '/feed/feed.json'
}
}
}
type Props = {
children: ReactNode;
}
export default function RootLayout({
children,
}: 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>
<UppyProvider>
<Navbar />
<NotificationCenter />
<div className="container">
{children}
<Footer />
</div>
</UppyProvider>
</AuthProvider>
</body>
</html>
)
}

View File

@ -0,0 +1,15 @@
import { IMeta } from "./types";
export interface IB2File {
id: number;
attributes: {
url: string;
key: string;
uploadId: string;
cdnUrl: string;
}
}
export interface IB2FileResponse {
data: IB2File;
meta: IMeta;
}

View File

@ -0,0 +1,5 @@
export interface IBlogPost {
slug: string;
title: string;
id: number;
}

View File

@ -0,0 +1,20 @@
// export const strapiUrl = (process.env.NODE_ENV === 'production') ? 'https://portal.futureporn.net' : 'https://chisel.sbtp:1337'
// export const siteUrl = (process.env.NODE_ENV === 'production') ? 'https://futureporn.net' : 'http://localhost:3000'
export const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
export const strapiUrl = process.env.NEXT_PUBLIC_STRAPI_URL
export const patreonSupporterBenefitId: string = '4760169'
export const patreonQuantumSupporterId: string = '10663202'
export const patreonVideoAccessBenefitId: string = '13462019'
export const skeletonHeight = '32pt'
export const skeletonBaseColor = '#000'
export const skeletonHighlightColor = '#000'
export const skeletonBorderRadius = 0
export const description = "The Galaxy's Best VTuber Hentai Site"
export const title = "Futureporn.net"
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 giteaUrl = 'https://gitea.futureporn.net'
export const projektMelodyEpoch = new Date('2020-02-07T23:21:48.000Z')

View File

@ -0,0 +1,24 @@
import { strapiUrl } from "./constants";
import fetchAPI from "./fetch-api";
export interface IContributor {
id: number;
attributes: {
name: string;
url?: string;
isFinancialDonor: boolean;
isVodProvider: boolean;
}
}
export async function getContributors(): Promise<IContributor[]|null> {
try {
const res = await fetchAPI(`/contributors`);
return res.data;
} catch (e) {
console.error(`error while fetching contributors`)
console.error(e);
return null;
}
}

View File

@ -0,0 +1,59 @@
import { parse } from 'date-fns';
import { format } from 'date-fns-tz'
import utcToZonedTime from 'date-fns-tz/utcToZonedTime'
import zonedTimeToUtc from 'date-fns-tz/zonedTimeToUtc'
const safeDateFormatString: string = "yyyyMMdd'T'HHmmss'Z'"
const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
export function getSafeDate(date: string | Date): string {
let dateString: string;
if (typeof date === 'string') {
const dateObject = utcToZonedTime(date, 'UTC');
dateString = format(dateObject, safeDateFormatString, { timeZone: 'UTC' });
} else {
dateString = format(date, safeDateFormatString, { timeZone: 'UTC' });
}
return dateString;
}
export function getDateFromSafeDate(safeDate: string): Date {
const date = parse(safeDate, safeDateFormatString, new Date())
const utcDate = zonedTimeToUtc(date, 'UTC')
return utcDate;
}
export function formatTimestamp(seconds: number = 0): string {
return new Date(seconds * 1000).toISOString().slice(11, 19);
}
export function formatUrlTimestamp(timestampInSeconds: number): string {
const hours = Math.floor(timestampInSeconds / 3600);
const minutes = Math.floor((timestampInSeconds % 3600) / 60);
const seconds = timestampInSeconds % 60;
return `${hours}h${minutes}m${seconds}s`;
}
export function parseUrlTimestamp(timestamp: string): number | null {
// Regular expression to match the "XhYmZs" format
const regex = /^(\d+)h(\d+)m(\d+)s$/;
const match = timestamp.match(regex);
if (match) {
const hours = parseInt(match[1], 10);
const minutes = parseInt(match[2], 10);
const seconds = parseInt(match[3], 10);
if (!isNaN(hours) && !isNaN(minutes) && !isNaN(seconds)) {
return hours * 3600 + minutes * 60 + seconds;
}
}
// If the format doesn't match or parsing fails, return null
return null;
}

View File

@ -0,0 +1,34 @@
// greets https://github.com/strapi/nextjs-corporate-starter/blob/main/frontend/src/app/%5Blang%5D/utils/fetch-api.tsx#L4
import qs from "qs";
import { strapiUrl } from "./constants";
export default async function fetchAPI(
path: string,
urlParamsObject = {},
options = {}
) {
try {
// Merge default and user options
const mergedOptions = {
next: { revalidate: 60 },
headers: {
"Content-Type": "application/json",
},
...options,
};
// Build request URL
const queryString = qs.stringify(urlParamsObject);
const requestUrl = `${strapiUrl}/api${path}${queryString ? `?${queryString}` : ""}`;
// Trigger API call
const response = await fetch(requestUrl, mergedOptions);
const data = await response.json();
return data;
} catch (error) {
console.error(error);
throw new Error(`Error while fetching data from API.`);
}
}

View File

@ -0,0 +1,32 @@
import { strapiUrl } from "./constants";
export async function fetchPaginatedData(apiEndpoint: string, pageSize: number, queryParams: Record<string, any> = {}): Promise<any[]> {
let data: any[] = [];
let totalDataCount: number = 0;
let totalRequestsNeeded: number = 1;
for (let requestCounter = 0; requestCounter < totalRequestsNeeded; requestCounter++) {
const humanReadableRequestCount = requestCounter + 1;
const params = new URLSearchParams({
'pagination[page]': humanReadableRequestCount.toString(),
'pagination[pageSize]': pageSize.toString(),
...queryParams,
});
const url = `${strapiUrl}${apiEndpoint}?${params}`;
const response = await fetch(url, {
method: 'GET'
});
const responseData = await response.json();
if (requestCounter === 0) {
totalDataCount = responseData.meta.pagination.total;
totalRequestsNeeded = Math.ceil(totalDataCount / pageSize);
}
data = data.concat(responseData.data);
}
return data;
}

View File

@ -0,0 +1,7 @@
export function buildIpfsUrl (urlFragment: string): string {
return `https://ipfs.io/ipfs/${urlFragment}`
}
export function buildPatronIpfsUrl (cid: string, token: string): string {
return `https://gw.futureporn.net/ipfs/${cid}?token=${token}`
}

View File

@ -0,0 +1,55 @@
import { strapiUrl, patreonVideoAccessBenefitId, giteaUrl } from './constants'
import { IAuthData } from '@/components/auth';
export interface IPatron {
username: string;
vanityLink?: string;
}
export interface ICampaign {
pledgeSum: number;
patronCount: number;
}
export interface IMarshalledCampaign {
data: {
attributes: {
pledge_sum: number,
patron_count: number
}
}
}
export function isEntitledToPatronVideoAccess(authData: IAuthData): boolean {
if (!authData.user?.patreonBenefits) return false;
const patreonBenefits = authData.user.patreonBenefits
return (patreonBenefits.includes(patreonVideoAccessBenefitId))
}
export async function getPatrons(): Promise<IPatron[]> {
const res = await fetch(`${strapiUrl}/api/patreon/patrons`);
return res.json();
}
export async function getCampaign(): Promise<ICampaign> {
const res = await fetch('https://www.patreon.com/api/campaigns/8012692', {
headers: {
accept: 'application/json'
},
next: {
revalidate: 43200 // 12 hour cache
}
})
const campaignData = await res.json();
const data = {
patronCount: campaignData.data.attributes.patron_count,
pledgeSum: campaignData.data.attributes.campaign_pledge_sum
}
return data
}

139
packages/next/app/lib/pm.ts Normal file
View File

@ -0,0 +1,139 @@
import matter from 'gray-matter';
const CACHE_TIME = 3600;
const GOAL_LABEL = 'Goal';
export interface IIssue {
id: number;
title: string;
comments: number;
updatedAt: string;
createdAt: string;
assignee: string | null;
name: string | null;
completedPercentage: number | null;
amountCents: number | null;
description: string | null;
}
export interface IGoals {
complete: IIssue[];
inProgress: IIssue[];
planned: IIssue[];
featuredFunded: IIssue;
featuredUnfunded: IIssue;
}
export interface IGiteaIssue {
id: number;
title: string;
body: string;
comments: number;
updated_at: string;
created_at: string;
assignee: string | null;
}
const bigHairyAudaciousGoal: IIssue = {
id: 55234234,
title: 'BHAG',
comments: 0,
updatedAt: '2023-09-20T08:54:01.373Z',
createdAt: '2023-09-20T08:54:01.373Z',
assignee: null,
name: 'Big Hairy Audacious Goal',
description: 'World domination!!!!!1',
amountCents: 100000000,
completedPercentage: 0.04
};
const defaultGoal: IIssue = {
id: 55234233,
title: 'e',
comments: 0,
updatedAt: '2023-09-20T08:54:01.373Z',
createdAt: '2023-09-20T08:54:01.373Z',
assignee: null,
name: 'Generic',
description: 'Getting started',
amountCents: 200,
completedPercentage: 1
};
export function calcPercent(goalAmountCents: number, totalPledgeSumCents: number): number {
if (!goalAmountCents || totalPledgeSumCents <= 0) {
return 0;
}
const output = Math.min(100, Math.floor((totalPledgeSumCents / goalAmountCents) * 100));
return output;
}
export async function getGoals(pledgeSum: number): Promise<IGoals | null> {
try {
const openData = await fetchAndParseData('open', pledgeSum);
const closedData = await fetchAndParseData('closed', pledgeSum);
const inProgress = filterByAssignee(openData);
const planned = filterByAssignee(openData, true);
const funded = filterAndSortGoals(openData.concat(closedData), true);
const unfunded = filterAndSortGoals(openData.concat(closedData), false);
console.log('the following are unfunded goals')
console.log(unfunded)
return {
complete: closedData,
inProgress,
planned,
featuredFunded: funded[funded.length - 1] || defaultGoal,
featuredUnfunded: unfunded[0] || bigHairyAudaciousGoal
};
} catch (error) {
console.error('Error fetching goals:', error);
return null;
}
}
function filterByAssignee(issues: IIssue[], isPlanned: boolean = false): IIssue[] {
return issues.filter((issue) => (isPlanned ? issue.assignee === null : issue.assignee !== null))
}
async function fetchAndParseData(state: 'open' | 'closed', pledgeSum: number): Promise<IIssue[]> {
const response = await fetch(`https://gitea.futureporn.net/api/v1/repos/futureporn/pm/issues?state=${state}&labels=${GOAL_LABEL}`, {
next: {
revalidate: CACHE_TIME,
tags: ['goals']
},
});
if (!response.ok) return [];
return response.json().then(issues => issues.map((g: IGiteaIssue) => parseGiteaGoal(g, pledgeSum)));
}
function filterAndSortGoals(issues: IIssue[], isFunded: boolean): IIssue[] {
return issues
.filter((issue) => issue.amountCents)
.filter((issue) => (issue.completedPercentage === 100) === isFunded)
.sort((b, a) => b.amountCents! - a.amountCents!);
}
function parseGiteaGoal(giteaIssue: IGiteaIssue, pledgeSum: number): IIssue {
const headMatter: any = matter(giteaIssue.body);
return {
id: giteaIssue.id,
title: giteaIssue.title,
comments: giteaIssue.comments,
updatedAt: giteaIssue.updated_at,
createdAt: giteaIssue.created_at,
assignee: giteaIssue.assignee,
name: headMatter.data.name || '',
description: headMatter.data.description || '',
amountCents: headMatter.data.amountCents || 0,
completedPercentage: calcPercent(headMatter.data.amountCents, pledgeSum)
};
}

View File

@ -0,0 +1,13 @@
export async function retry(fn: Function, maxRetries: number) {
let retries = 0;
while (retries < maxRetries) {
try {
return await fn();
} catch (error) {
console.error(`Error during fetch attempt ${retries + 1}:`, error);
retries++;
}
}
console.error(`Max retries (${maxRetries}) reached. Giving up.`);
return null;
}

View File

@ -0,0 +1,51 @@
import { authorName, authorEmail, siteUrl, title, description, siteImage, favicon, authorLink } from './constants'
import { Feed } from "feed";
import { getVods, getUrl, IVod } from '@/lib/vods'
import { ITagVodRelation } from '@/lib/tag-vod-relations';
export async function generateFeeds() {
const feedOptions = {
id: siteUrl,
title: title,
description: description,
link: siteUrl,
language: 'en',
image: siteImage,
favicon: favicon,
copyright: '',
generator: ' ',
feedLinks: {
json: `${siteUrl}/feed/feed.json`,
atom: `${siteUrl}/feed/feed.xml`
},
author: {
name: authorName,
email: authorEmail,
link: authorLink
}
};
const feed = new Feed(feedOptions);
const vods = await getVods()
vods.data.map((vod: IVod) => {
feed.addItem({
title: vod.attributes.title || vod.attributes.announceTitle,
description: vod.attributes.title, // @todo vod.attributes.spoiler or vod.attributes.note could go here
content: vod.attributes.tagVodRelations.data.map((tvr: ITagVodRelation) => tvr.attributes.tag.data.attributes.name).join(' '),
link: getUrl(vod, vod.attributes.vtuber.data.attributes.slug, vod.attributes.date2),
date: new Date(vod.attributes.date2),
image: vod.attributes.vtuber.data.attributes.image
})
})
return {
atom1: feed.atom1(),
rss2: feed.rss2(),
json1: feed.json1()
}
}

View File

@ -0,0 +1,16 @@
import type { MutableRefObject, RefCallback } from 'react';
type RefType<T> = MutableRefObject<T> | RefCallback<T> | null;
export const shareRef = <T>(refA: RefType<T>, refB: RefType<T>): RefCallback<T> => instance => {
if (typeof refA === 'function') {
refA(instance);
} else if (refA && 'current' in refA) {
(refA as MutableRefObject<T>).current = instance as T; // Use type assertion to tell TypeScript the type
}
if (typeof refB === 'function') {
refB(instance);
} else if (refB && 'current' in refB) {
(refB as MutableRefObject<T>).current = instance as T; // Use type assertion to tell TypeScript the type
}
};

View File

@ -0,0 +1,369 @@
import { strapiUrl, siteUrl } from './constants';
import { getSafeDate } from './dates';
import { IVodsResponse } from './vods';
import { IVtuber, IVtuberResponse } from './vtubers';
import { ITweetResponse } from './tweets';
import { IMeta } from './types';
import qs from 'qs';
export interface IStream {
id: number;
attributes: {
date: string;
archiveStatus: 'good' | 'issue' | 'missing';
vods: IVodsResponse;
cuid: string;
vtuber: IVtuberResponse;
tweet: ITweetResponse;
isChaturbateStream: boolean;
isFanslyStream: boolean;
}
}
export interface IStreamResponse {
data: IStream;
meta: IMeta;
}
export interface IStreamsResponse {
data: IStream[];
meta: IMeta;
}
const fetchStreamsOptions = {
next: {
tags: ['streams']
}
}
export async function getStreamByCuid(cuid: string): Promise<IStream> {
const query = qs.stringify({
filters: {
cuid: {
$eq: cuid
}
},
pagination: {
limit: 1
},
populate: {
vtuber: {
fields: ['slug', 'displayName']
},
tweet: {
fields: ['isChaturbateInvite', 'isFanslyInvite', 'url']
},
vods: {
fields: ['note', 'cuid', 'publishedAt'],
populate: {
tagVodRelations: {
fields: ['id']
},
timestamps: '*'
}
}
}
});
const res = await fetch(`${strapiUrl}/api/streams?${query}`);
const json = await res.json();
return json.data[0];
}
export function getUrl(stream: IStream, slug: string, date: string): string {
return `${siteUrl}/vt/${slug}/stream/${getSafeDate(date)}`
}
export function getPaginatedUrl(): (slug: string, pageNumber: number) => string {
return (slug: string, pageNumber: number) => {
return `${siteUrl}/vt/${slug}/streams/${pageNumber}`
}
}
export function getLocalizedDate(stream: IStream): string {
return new Date(stream.attributes.date).toLocaleDateString()
}
export async function getStreamsForYear(year: number): Promise<IStream[]> {
const startOfYear = new Date(year, 0, 0);
const endOfYear = new Date(year, 11, 31);
const pageSize = 100; // Number of records per page
let currentPage = 0;
let allStreams: IStream[] = [];
while (true) {
const query = qs.stringify({
filters: {
date: {
$gte: startOfYear,
$lte: endOfYear,
},
},
populate: {
vtuber: {
fields: ['displayName']
}
},
pagination: {
page: currentPage,
pageSize: pageSize,
}
});
const res = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions);
if (!res.ok) {
// Handle error if needed
console.error('here is the res.body')
console.error((await res.text()));
throw new Error(`Error fetching streams: ${res.status}`);
}
const json = await res.json();
const streams = json as IStreamsResponse;
if (streams.data.length === 0) {
// No more records, break the loop
break;
}
allStreams = [...allStreams, ...streams.data];
currentPage += pageSize;
}
return allStreams;
}
export async function getStream(id: number): Promise<IStream> {
const query = qs.stringify({
filters: {
id: {
$eq: id
}
}
});
const res = await fetch(`${strapiUrl}/api/vods?${query}`, fetchStreamsOptions);
const json = await res.json();
return json.data;
}
export async function getAllStreams(archiveStatuses = ['missing', 'issue', 'good']): Promise<IStream[]> {
const pageSize = 100; // Adjust this value as needed
const sortDesc = true; // Adjust the sorting direction as needed
const allStreams: IStream[] = [];
let currentPage = 1;
while (true) {
const query = qs.stringify({
populate: {
vtuber: {
fields: ['slug', 'displayName', 'image', 'imageBlur', 'themeColor'],
},
muxAsset: {
fields: ['playbackId', 'assetId'],
},
thumbnail: {
fields: ['cdnUrl', 'url'],
},
tagstreamRelations: {
fields: ['tag'],
populate: ['tag'],
},
videoSrcB2: {
fields: ['url', 'key', 'uploadId', 'cdnUrl'],
},
tweet: {
fields: ['isChaturbateInvite', 'isFanslyInvite']
}
},
filters: {
archiveStatus: {
'$in': archiveStatuses
}
},
sort: {
date: sortDesc ? 'desc' : 'asc',
},
pagination: {
pageSize,
page: currentPage,
},
});
const response = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions);
const responseData = await response.json();
if (!responseData.data || responseData.data.length === 0) {
// No more data to fetch
break;
}
allStreams.push(...responseData.data);
currentPage++;
}
return allStreams;
}
export async function getStreamForVtuber(vtuberId: number, safeDate: string): Promise<IStream> {
const query = qs.stringify({
populate: {
vods: {
fields: [
'id',
'date'
]
},
tweet: {
fields: [
'id'
]
}
}
});
const response = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions);
if (response.status !== 200) throw new Error('network fetch error while attempting to getStreamForVtuber');
const responseData = await response.json();
return responseData;
}
export async function getAllStreamsForVtuber(vtuberId: number, archiveStatuses = ['missing', 'issue', 'good']): Promise<IStream[]> {
const maxRetries = 3;
let retries = 0;
let allStreams: IStream[] = [];
let currentPage = 1;
while (retries < maxRetries) {
try {
const query = qs.stringify({
populate: '*',
filters: {
archiveStatus: {
'$in': archiveStatuses
},
vtuber: {
id: {
$eq: vtuberId
}
}
},
sort: {
date: 'desc',
},
pagination: {
pageSize: 100,
page: currentPage,
},
});
console.log(`strapiUrl=${strapiUrl}`)
const response = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions);
if (response.status !== 200) {
// If the response status is not 200 (OK), consider it a network failure
const bod = await response.text();
console.log(response.status);
console.log(bod);
retries++;
continue;
}
const responseData = await response.json();
if (!responseData.data || responseData.data.length === 0) {
// No more data to fetch
break;
}
allStreams.push(...responseData.data);
currentPage++;
} catch (error) {
// Network failure or other error occurred
retries++;
}
}
if (retries === maxRetries) {
throw new Error(`Failed to fetch streams after ${maxRetries} retries.`);
}
return allStreams;
}
export async function getStreamsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25, sortDesc = true): Promise<IStreamsResponse> {
const query = qs.stringify(
{
populate: {
vtuber: {
fields: [
'id',
]
}
},
filters: {
vtuber: {
id: {
$eq: vtuberId
}
}
},
pagination: {
page: page,
pageSize: pageSize
},
sort: {
date: (sortDesc) ? 'desc' : 'asc'
}
}
)
return fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions)
.then((res) => res.json())
}
// /**
// * This returns stale data, because futureporn-historian is broken.
// * @todo get live data from historian
// * @see https://gitea.futureporn.net/futureporn/futureporn-historian/issues/1
// */
// export async function getProgress(vtuberSlug: string): Promise<{ complete: number; total: number }> {
// const query = qs.stringify({
// filters: {
// vtuber: {
// slug: {
// $eq: vtuberSlug
// }
// }
// }
// })
// const data = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions)
// .then((res) => res.json())
// .then((g) => {
// return g
// })
// const total = data.meta.pagination.total
// return {
// complete: total,
// total: total
// }
// }

View File

@ -0,0 +1,191 @@
/**
* Tag Vod Relations are an old name for what I'm now calling, "VodTag"
*
* VodTags are Tags related to Vods
*
*/
import qs from 'qs';
import { strapiUrl } from './constants'
import { ITagResponse, IToyTagResponse } from './tags';
import { IVod, IVodResponse } from './vods';
import { IAuthData } from '@/components/auth';
import { IMeta } from './types';
export interface ITagVodRelation {
id: number;
attributes: {
tag: ITagResponse | IToyTagResponse;
vod: IVodResponse;
creatorId: number;
createdAt: string;
}
}
export interface ITagVodRelationsResponse {
data: ITagVodRelation[];
meta: IMeta;
}
export async function deleteTvr(authData: IAuthData, tagId: number) {
return fetch(`${strapiUrl}/api/tag-vod-relations/deleteMine/${tagId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authData.accessToken}`,
'Content-Type': 'application/json'
}
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
else return res.json();
})
.catch((e) => {
console.error(e);
// setError('root.serverError', { message: e.message })
})
}
export async function readTagVodRelation(accessToken: string, tagId: number, vodId: number): Promise<ITagVodRelation> {
if (!tagId) throw new Error('readTagVodRelation requires tagId as second param');
if (!vodId) throw new Error('readTagVodRelation requires vodId as second param');
const findQuery = qs.stringify({
filters: {
$and: [
{
tag: tagId
}, {
vod: vodId
}
]
}
});
const res = await fetch(`${strapiUrl}/api/tag-vod-relations?${findQuery}`);
const json = await res.json();
return json.data[0];
}
export async function createTagVodRelation(accessToken: string, tagId: number, vodId: number): Promise<ITagVodRelation> {
if (!accessToken) throw new Error('Must be logged in');
if (!tagId) throw new Error('tagId is required.');
if (!vodId) throw new Error('vodId is required.');
const payload = {
tag: tagId,
vod: vodId
}
const res = await fetch(`${strapiUrl}/api/tag-vod-relations`, {
method: 'POST',
body: JSON.stringify({ data: payload }),
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json'
}
})
const json = await res.json();
console.log(json)
return json.data;
}
export async function readOrCreateTagVodRelation (accessToken: string, tagId: number, vodId: number): Promise<ITagVodRelation> {
console.log(`Checking if the tagVodRelation with tagId=${tagId}, vodId=${vodId} already exists`);
const existingTagVodRelation = await readTagVodRelation(accessToken, tagId, vodId);
if (!!existingTagVodRelation) {
console.log(`there is an existing TVR so we return it`);
console.log(existingTagVodRelation);
return existingTagVodRelation
}
const newTagVodRelation = await createTagVodRelation(accessToken, tagId, vodId);
return newTagVodRelation;
}
// export async function createTagAndTvr(setError: Function, authData: IAuthData, tagName: string, vodId: number) {
// if (!authData) throw new Error('Must be logged in');
// if (!tagName || tagName === '') throw new Error('tagName cannot be empty');
// const data = {
// tagName: tagName,
// vodId: vodId
// };
// try {
// const res = await fetch(`${strapiUrl}/api/tag-vod-relations/tag`, {
// method: 'POST',
// body: JSON.stringify({ data }),
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer ${authData.accessToken}`
// },
// });
// const json = await res.json();
// return json.data;
// } catch (e) {
// setError('global', { type: 'idk', message: e })
// }
// }
export async function getTagVodRelationsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25): Promise<ITagVodRelationsResponse|null> {
// 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.
const res = await fetch(`${strapiUrl}/api/tag-vod-relations?${query}`);
if (!res.ok) return null;
const tvrs = await res.json()
return tvrs;
}

View File

@ -0,0 +1,139 @@
import { strapiUrl } from './constants'
import { fetchPaginatedData } from './fetchers';
import { IVod } from './vods';
import slugify from 'slugify';
import { IToy } from './toys';
import { IAuthData } from '@/components/auth';
import qs from 'qs';
import { IMeta } from './types';
export interface ITag {
id: number;
attributes: {
name: string;
count: number;
}
}
export interface ITagsResponse {
data: ITag[];
meta: IMeta;
}
export interface ITagResponse {
data: ITag;
meta: IMeta;
}
export interface IToyTagResponse {
data: IToyTag;
meta: IMeta;
}
export interface IToyTag extends ITag {
toy: IToy;
}
export function getTagHref(name: string): string {
return `/tags/${slugify(name)}`
}
export async function createTag(accessToken: string, tagName: string): Promise<ITag> {
const payload = {
name: slugify(tagName)
};
const res = await fetch(`${strapiUrl}/api/tags`, {
method: 'POST',
headers: {
'authorization': `Bearer ${accessToken}`,
'accept': 'application/json',
'content-type': 'application/json'
},
body: JSON.stringify({ data: payload })
});
const json = await res.json();
console.log(json);
if (!!json?.error) throw new Error(json.error.message);
if (!json?.data) throw new Error('created tag was missing data');
return json.data as ITag;
}
export async function readTag(accessToken: string, tagName: string): Promise<ITag | null> {
const findQuery = qs.stringify({
filters: {
name: {
$eq: tagName
}
}
});
const findResponse = await fetch(`${strapiUrl}/api/tags?${findQuery}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
const json = await findResponse.json();
return json.data[0];
}
export async function readOrCreateTag(accessToken: string, tagName: string): Promise<ITag> {
console.log(`Checking if the tagName=${tagName} already exists`);
const existingTag = await readTag(accessToken, tagName);
if (!!existingTag) {
console.log('there is an existing tag so we return it');
console.log(existingTag);
return existingTag;
}
const newTag = await createTag(accessToken, tagName);
return newTag;
}
export async function getTags(): Promise<ITag[]> {
const tagVodRelations = await fetchPaginatedData('/api/tag-vod-relations', 100, { 'populate[0]': 'tag', 'populate[1]': 'vod' });
// Create a Map to store tag data, including counts and IDs
const tagDataMap = new Map<string, { id: number, count: number }>();
// Populate the tag data map with counts and IDs
tagVodRelations.forEach(tvr => {
const tagName = tvr.attributes.tag.data.attributes.name;
const tagId = tvr.attributes.tag.data.id;
if (!tagDataMap.has(tagName)) {
tagDataMap.set(tagName, { id: tagId, count: 1 });
} else {
const existingData = tagDataMap.get(tagName);
if (existingData) {
tagDataMap.set(tagName, { id: existingData.id, count: existingData.count + 1 });
}
}
});
// Create an array of Tag objects with id, name, and count
const tags = Array.from(tagDataMap.keys()).map(tagName => {
const tagData = tagDataMap.get(tagName);
return {
id: tagData ? tagData.id : -1,
attributes: {
name: tagName,
count: tagData ? tagData.count : 0,
}
};
});
return tags;
}

View File

@ -0,0 +1,127 @@
import qs from 'qs';
import { strapiUrl } from './constants'
import { IAuthData } from '@/components/auth';
import { ITagsResponse, ITag, ITagResponse } from './tags';
import { IMeta } from './types';
export interface ITimestamp {
id: number;
attributes: {
time: number;
tagName: string;
tnShort: string;
tagId: number;
vodId: number;
tag: ITagResponse;
createdAt: string;
creatorId: number;
}
}
export interface ITimestampResponse {
data: ITimestamp;
meta: IMeta;
}
export interface ITimestampsResponse {
data: ITimestamp[];
meta: IMeta;
}
function truncateString(str: string, maxLength: number) {
if (str.length <= maxLength) {
return str;
}
return str.substring(0, maxLength - 1) + '…';
}
export function deleteTimestamp(authData: IAuthData, tsId: number) {
return fetch(`${strapiUrl}/api/timestamps/deleteMine/${tsId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${authData.accessToken}`,
'Content-Type': 'application/json'
}
})
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
else return res.json();
})
.catch((e) => {
console.error(e);
// setError('root.serverError', { message: e.message })
})
}
export async function createTimestamp(
authData: IAuthData,
tagId: number,
vodId: number,
time: number
): Promise<ITimestamp | null> {
if (!authData?.user?.id || !authData?.accessToken) throw new Error('User must be logged in to create timestamps');
const query = qs.stringify({
populate: '*'
});
const response = await fetch(`${strapiUrl}/api/timestamps?${query}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authData.accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: {
time: Math.floor(time),
tag: tagId,
vod: vodId,
creatorId: authData.user.id
}
})
});
const json = await response.json();
if (!response.ok) {
throw new Error(json?.error?.message || response.statusText);
}
return json.data;
}
export async function getTimestampsForVod(vodId: number, page: number = 1, pageSize: number = 25): Promise<ITimestamp[]> {
const query = qs.stringify({
filters: {
vod: {
id: {
$eq: vodId,
},
},
},
populate: '*',
sort: 'time:asc',
pagination: {
page: page,
pageSize: pageSize,
},
});
const response = await fetch(`${strapiUrl}/api/timestamps?${query}`);
const data = await response.json() as ITimestampsResponse;
const timestamps: ITimestamp[] = data.data || [];
// If there are more pages, recursively fetch them and concatenate the results
if (data.meta.pagination && (data.meta.pagination.page < data.meta.pagination.pageCount)) {
const nextPage = (data.meta.pagination.page + 1);
const nextPageTimestamps = await getTimestampsForVod(vodId, nextPage, pageSize);
timestamps.push(...nextPageTimestamps);
}
return timestamps;
}

View File

@ -0,0 +1,42 @@
import { ITag, ITagResponse, ITagsResponse } from '@/lib/tags'
import { IMeta } from './types';
export interface IToysResponse {
data: IToy[];
meta: IMeta;
}
export interface IToy {
id: number;
attributes: {
tags: ITagsResponse;
linkTag: ITagResponse;
make: string;
model: string;
aspectRatio: string;
image2: string;
}
}
interface IToysListProps {
toys: IToysResponse;
page: number;
pageSize: number;
}
/** This endpoint doesn't exist at the moment, but definitely could in the future */
// export function getUrl(toy: IToy): string {
// return `${siteUrl}/toy/${toy.name}`
// }
// 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
// }
// }

View File

@ -0,0 +1,28 @@
import { IVtuberResponse } from "./vtubers";
import { IMeta } from "./types";
export interface ITweet {
id: number;
attributes: {
date: string;
date2: string;
isChaturbateInvite: boolean;
isFanslyInvite: boolean;
cuid: string;
json: string;
id_str: string;
url: string;
vtuber: IVtuberResponse;
}
}
export interface ITweetResponse {
data: ITweet;
meta: IMeta;
}
export interface ITweetsResponse {
data: ITweet[];
meta: IMeta;
}

View File

@ -0,0 +1,28 @@
export interface IMuxAsset {
id: number;
attributes: {
playbackId: string;
assetId: string;
}
}
export interface IPagination {
page: number;
pageSize: number;
pageCount: number;
total: number;
}
export interface IMuxAssetResponse {
data: IMuxAsset;
meta: IMeta;
}
export interface IMeta {
pagination: IPagination;
}

Some files were not shown because too many files have changed in this diff Show More