init
This commit is contained in:
commit
d60c6ac3bb
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
lts/iron
|
|
@ -0,0 +1 @@
|
|||
/ futureporn.net
|
|
@ -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" ]
|
|
@ -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.
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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's a lofty goal, but Futureporn aims to become <b>the Galaxy's best VTuber hentai site.</b></p>
|
||||
</div>
|
||||
|
||||
<h2>How do we get there?</h2>
|
||||
|
||||
<div className="section">
|
||||
<h3>1. Solve the viewer'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'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>' 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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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'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>
|
||||
)
|
||||
}
|
|
@ -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() })
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 doesn’t store any VODs. When I missed a stream, I felt sad. I felt like I had missed out and there’s no way I’d ever find out what happened.</p>
|
||||
|
||||
<p>I’m pretty handy with computer software. Creating programs and websites has been my biggest passion for my entire life. In order to never miss a ProjektMelody livestream again, I resolved to create some software that would automatically record Melody’s Chaturbate streams.</p>
|
||||
|
||||
<p>I put the project on hold for a few months, because I didn’t think I could make a website that could handle the traffic that the Science Team would generate.</p>
|
||||
|
||||
<p>I couldn’t shake the idea, though. I wanted Futureporn to exist no matter what!</p>
|
||||
|
||||
<p>I’ve been working on this project off and on for about a year and a half. It’s gone through several iterations, and each iteration has taught me something new. Right now, the website is usable for finding and downloading ProjektMelody Chaturbate VODs. Every VOD has a link to Melody’s tweet which originally announced the stream, and a title/description derived from said tweet. I have archived 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 Melody’s livestreams. Now, the project is becoming a sort of a time capsule. We’ve all seen how Melody has been de-platformed a half dozen times, and I’ve taken this to heart. Platforms are a problem for data preservation! This is one of the reasons for why I chose to use the Inter-Planetary File System (<Link target="_blank" href="https://ipfs.io/">IPFS<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.)</p>
|
||||
|
||||
<p>IPFS can end 404s through “pinning,” a way of mirroring a file across several different computers. It’s a way for computers to work together to serve content instead of working independently, thus gaining redundancy and performance benefits. I see a future where pinning files on IPFS becomes as easy as pinning a photo on Pinterest. Fans of ProjektMelody can pin the VODs on Futureporn, increasing that VOD’s replication and servability to future viewers.</p>
|
||||
|
||||
<p>But wait, there’s more! I have been thinking about a bunch of other stuff that could be done with past VODs. I think the most exciting thing would be to use computer vision to parse Melody’s vibrator activity from the video, and export to a data file. This data file could be used to send good vibes to a viewer’s vibrator in-sync with VOD playback. Feel what Melody feels! Very exciting, very sexy! This is a long-term goal for Futureporn.</p>
|
||||
|
||||
<p>I have several goals for Futureporn, as listed on the <Link href="/goals">Goals page</Link>. A bunch of them have to do with increasing video playback performance, user interface design, but there’s a few that are pretty eccentric… Serving ProjektMelody VODs to Mars, for example!</p>
|
||||
|
||||
<p>I hope this site is useful to all the Science Team!</p>
|
||||
|
||||
<article className="mt-5 message is-success">
|
||||
<div className="message-body">
|
||||
<p>Futureporn needs financial support to continue improving. If you enjoy this website, please consider <Link target="_blank" href="https://patreon.com/CJ_Clippy">becoming a patron<FontAwesomeIcon icon={faExternalLinkAlt} className="fas fa-external-link-alt" /></Link>.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
|
@ -0,0 +1,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>> {post.title}</h2>
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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}</>
|
||||
)
|
||||
}
|
|
@ -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 }
|
|
@ -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">↑ 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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
// )
|
||||
// }
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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">…</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">…</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{endPage !== pageCount && (
|
||||
<li>
|
||||
<Link href={getPagePath(pageCount)} className={`pagination-link ${pageCount === page ? 'is-current' : ''}`}>
|
||||
<span>
|
||||
{pageCount}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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> <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> </span><span>{platformsList}</span><br></br>
|
||||
<span><b>UTC Datetime</b> </span><time dateTime={date.toISOString()}>{date.toISOString()}</time><br></br>
|
||||
<span><b>Local Datetime</b> </span><span>{date.toLocaleDateString()} {date.toLocaleTimeString()}</span><br></br>
|
||||
<span><b>Lunar Phase</b> </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>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}'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>
|
||||
)
|
||||
};
|
|
@ -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>
|
||||
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
// );
|
||||
// }
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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'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'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'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'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'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'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 |
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export interface IBlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
id: number;
|
||||
}
|
|
@ -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')
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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.`);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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}`
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
};
|
|
@ -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
|
||||
// }
|
||||
// }
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
// }
|
||||
// }
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue