commit d60c6ac3bba5164d0db448579efdd4388fe2eee1 Author: Chris Grimmett Date: Sat Jan 20 08:16:14 2024 -0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7da9ff0 --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04a0ccf --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aac7ccf --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/compose.prod.yml b/compose.prod.yml new file mode 100644 index 0000000..a1c6841 --- /dev/null +++ b/compose.prod.yml @@ -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" \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..f220045 --- /dev/null +++ b/compose.yml @@ -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 \ No newline at end of file diff --git a/packages/next/.eslintrc.json b/packages/next/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/packages/next/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/packages/next/.gitignore b/packages/next/.gitignore new file mode 100644 index 0000000..473707c --- /dev/null +++ b/packages/next/.gitignore @@ -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 diff --git a/packages/next/.nvmrc b/packages/next/.nvmrc new file mode 100644 index 0000000..9de2256 --- /dev/null +++ b/packages/next/.nvmrc @@ -0,0 +1 @@ +lts/iron diff --git a/packages/next/CHECKS b/packages/next/CHECKS new file mode 100644 index 0000000..6731352 --- /dev/null +++ b/packages/next/CHECKS @@ -0,0 +1 @@ +/ futureporn.net \ No newline at end of file diff --git a/packages/next/Dockerfile.old b/packages/next/Dockerfile.old new file mode 100644 index 0000000..98094ed --- /dev/null +++ b/packages/next/Dockerfile.old @@ -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" ] diff --git a/packages/next/LICENSE b/packages/next/LICENSE new file mode 100644 index 0000000..7c53cea --- /dev/null +++ b/packages/next/LICENSE @@ -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. \ No newline at end of file diff --git a/packages/next/README.md b/packages/next/README.md new file mode 100644 index 0000000..1bd2474 --- /dev/null +++ b/packages/next/README.md @@ -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 + diff --git a/packages/next/app.json b/packages/next/app.json new file mode 100644 index 0000000..31825ab --- /dev/null +++ b/packages/next/app.json @@ -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 + } + ] + } +} \ No newline at end of file diff --git a/packages/next/app/about/page.tsx b/packages/next/app/about/page.tsx new file mode 100644 index 0000000..87de407 --- /dev/null +++ b/packages/next/app/about/page.tsx @@ -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 ( + <> +
+
+ +
+ +

About

+
+

Futureporn is a fanmade public archive of NSFW R18 vtuber livestreams.

+
+ +

Mission

+
+ +

It's a lofty goal, but Futureporn aims to become the Galaxy's best VTuber hentai site.

+
+ +

How do we get there?

+ +
+

1. Solve the viewer's common problems

+ +

Viewers want to watch livestream VODs on their own time. Futureporn collects vods from public streams, and caches them for later viewing.

+ +

Viewers want to find content that interests them. Futureporn enables vod tagging for easy browsing.

+
+ +
+

2. Solve the streamer's common problems

+ +

Platforms like PH are not rising to the needs of VTubers. Instead of offering support and resources, they restrict and ban top talent.

+ +

Futureporn is different, embracing the medium and leveraging emerging technologies to amplify VTuber success.

+
+ +
+

3. Scale beyond Earth

+ +

Piggybacking on IPFS' content-addressable capabilities and potential to end 404s, VODs preserved here can withstand the test of time, and eventually persist off-world.

+
+ +
+
+
+

Futureporn needs financial support to continue improving. If you enjoy this website, please consider becoming a patron.

+
+
+
+ +
+
+
+ + ) +} diff --git a/packages/next/app/api/blogs/route.ts b/packages/next/app/api/blogs/route.ts new file mode 100644 index 0000000..dca5232 --- /dev/null +++ b/packages/next/app/api/blogs/route.ts @@ -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); +} diff --git a/packages/next/app/api/page.tsx b/packages/next/app/api/page.tsx new file mode 100644 index 0000000..f1a47d8 --- /dev/null +++ b/packages/next/app/api/page.tsx @@ -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 ( +
+
+

Futureporn API

+

Futureporn Application Programmable Interface (API) for developers and power users

+
+
+
+
+

RSS Feed

+

Keep up to date with new VODs using Real Simple Syndication (RSS).

+ +

Don't have a RSS reader? Futureporn recommends Fraidycat

+ +
+

ATOM

+

RSS

+

JSON

+
+
+
+ +
+
+

Data API

+

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.

+

Futureporn API Version 1

+
+
+ +
+
+

IPFS Cluster Template

+

The IPFS Cluster Template allows other IPFS cluster instances to join the Futureporn.net IPFS cluster as a follower peer . Cluster peers automatically pin (replicate) the IPFS content listed on this website.

+ +

Basic instructions are as follows

+

1. Download & install both kubo and ipfs-cluster-follow onto your server.

+

2. Initialize your ipfs repo & start the ipfs daemon

+

3. Join the cluster using ipfs-cluster-follow

+ +

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.

+ + + {({ className, style, tokens, getLineProps, getTokenProps }) => ( +
+                                        {tokens.map((line, i) => (
+                                            
+ {line.map((token, key) => ( + + ))} +
+ ))} +
+ )} +
+ + + + + +

Futureporn IPFS Cluster Template (service.json)

+
+
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/packages/next/app/api/revalidate/route.ts b/packages/next/app/api/revalidate/route.ts new file mode 100644 index 0000000..bad85ce --- /dev/null +++ b/packages/next/app/api/revalidate/route.ts @@ -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() }) +} \ No newline at end of file diff --git a/packages/next/app/api/service.json/route.ts b/packages/next/app/api/service.json/route.ts new file mode 100644 index 0000000..9aed096 --- /dev/null +++ b/packages/next/app/api/service.json/route.ts @@ -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); +} \ No newline at end of file diff --git a/packages/next/app/api/v1.json/route.ts b/packages/next/app/api/v1.json/route.ts new file mode 100644 index 0000000..4433dc1 --- /dev/null +++ b/packages/next/app/api/v1.json/route.ts @@ -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 { + 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); + } +} \ No newline at end of file diff --git a/packages/next/app/blog/2021-10-29-the-story-of-futureporn/page.tsx b/packages/next/app/blog/2021-10-29-the-story-of-futureporn/page.tsx new file mode 100644 index 0000000..a91a0bf --- /dev/null +++ b/packages/next/app/blog/2021-10-29-the-story-of-futureporn/page.tsx @@ -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 ( +
+
+ +
+ + + + +

The Story of Futureporn

+ +

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.

+ +

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.

+ +

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.

+ +

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.

+ +

I couldn’t shake the idea, though. I wanted Futureporn to exist no matter what!

+ +

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.

+ +

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 (IPFS.)

+ +

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.

+ +

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.

+ +

I have several goals for Futureporn, as listed on the Goals page. 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!

+ +

I hope this site is useful to all the Science Team!

+ +
+
+

Futureporn needs financial support to continue improving. If you enjoy this website, please consider becoming a patron.

+
+
+ +
+
+
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/blog/page.tsx b/packages/next/app/blog/page.tsx new file mode 100644 index 0000000..994e452 --- /dev/null +++ b/packages/next/app/blog/page.tsx @@ -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 ( +
+
+ +

All Blog Posts

+
+ +
+ {posts.map((post: IBlogPost) => ( +
+ +

> {post.title}

+ +
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/packages/next/app/components/archive-progress.tsx b/packages/next/app/components/archive-progress.tsx new file mode 100644 index 0000000..2de480b --- /dev/null +++ b/packages/next/app/components/archive-progress.tsx @@ -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 ( +
+

{eligibleStreams}/{totalStreams} Streams Archived ({completedPercentage}%)

+ {completedPercentage}% +
+ ) +} \ No newline at end of file diff --git a/packages/next/app/components/auth.tsx b/packages/next/app/components/auth.tsx new file mode 100644 index 0000000..e732be5 --- /dev/null +++ b/packages/next/app/components/auth.tsx @@ -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(null); + +interface IAuthContextProps { + children: ReactNode; +} +export function AuthProvider({ children }: IAuthContextProps): React.JSX.Element { + const { value: authData, set: setAuthData } = useLocalStorageValue('authData', { + defaultValue: null, + }); + + const { value: lastVisitedPath, set: setLastVisitedPath } = useLocalStorageValue('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 ( + + {children} + + ); +} + +export function LoginButton() { + const context = useContext(AuthContext); + if (!context) return ; + const { login } = context; + return ( + + ); +} + +export function LogoutButton() { + const context = useContext(AuthContext); + if (!context) return <>; + const { logout } = context; + return ( + + ); +} + +export function useAuth(): IUseAuth { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/packages/next/app/components/cal.tsx b/packages/next/app/components/cal.tsx new file mode 100644 index 0000000..81ee719 --- /dev/null +++ b/packages/next/app/components/cal.tsx @@ -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 ( + <> +
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/components/contributors.tsx b/packages/next/app/components/contributors.tsx new file mode 100644 index 0000000..74f877c --- /dev/null +++ b/packages/next/app/components/contributors.tsx @@ -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 ( + + + + ) + const contributorList = contributors.map((contributor, index) => ( + + {contributor.attributes.url ? ( + + {contributor.attributes.name} + + + ) : ( + contributor.attributes.name + )} + {index !== contributors.length - 1 ? ", " : ""} + + )); + return ( + <>{contributorList} + ) +} \ No newline at end of file diff --git a/packages/next/app/components/custom-hls-player.tsx b/packages/next/app/components/custom-hls-player.tsx new file mode 100644 index 0000000..f46724c --- /dev/null +++ b/packages/next/app/components/custom-hls-player.tsx @@ -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 ( +
+ HLS is not supported in your browser. Please try a different browser. +
+ ); +} + +const useHls = (src: string, options: Options | null) => { + const hls = useRef(new Hls()); + const hasQuality = useRef(false); + const [plyrOptions, setPlyrOptions] = useState(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; + + return ( + <> + + + ); +}); + + +CustomPlyrInstance.displayName = 'CustomPlyrInstance' + +export { CustomPlyrInstance } \ No newline at end of file diff --git a/packages/next/app/components/footer.tsx b/packages/next/app/components/footer.tsx new file mode 100644 index 0000000..ff4431e --- /dev/null +++ b/packages/next/app/components/footer.tsx @@ -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 ( + <> +
+
+ +
+
+

Sitemap

+
    +
  • ↑ Top of page
  • +
  • Vtubers
  • +
  • Stream Archive
  • +
  • About
  • +
  • FAQ
  • +
  • Goals
  • +
  • Patrons
  • +
  • Tags
  • +
  • RSS Feed
  • +
  • API
  • +
  • Blog
  • +
  • Status
  • +
  • Upload
  • +
  • Profile
  • +
+
+
+

+ Futureporn.net is made with ❤️ by CJ_Clippy +

+

+ Made possible by generous + + donations + + + + from + +

+

+ VOD contributions by +

+

+ + + Git Repo + + +

+ +

+ + + Reddit Thread + + +

+ +

+ + + Discord Server + + +

+
+
+ +
+ +
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/components/funding-goal.tsx b/packages/next/app/components/funding-goal.tsx new file mode 100644 index 0000000..12b4119 --- /dev/null +++ b/packages/next/app/components/funding-goal.tsx @@ -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 { + 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 ( + <> + {/*

+ pledgeSum:{JSON.stringify(pledgeSum, null, 2)} +

+

+ patronCount:{JSON.stringify(patronCount, null, 2)} +

+

featuredFunded:{JSON.stringify(goals.featuredFunded)}

+

featuredUnfunded:{JSON.stringify(goals.featuredUnfunded)}

*/} + + {/*
+                
+                    {JSON.stringify(goals, null, 2)}
+                
+            
*/} + +
+
+ Funding Goal +
+ + CJ_Clippy + +
+
+
+
+ {/* the most recently funded goal */} +
+ {/* const { featuredFunded, featuredUnfunded } = goals; + if (!featuredFunded?.amountCents || !featuredFunded?.completedPercentage) return <> + if (!featuredUnfunded?.amountCents || !featuredUnfunded?.completedPercentage) return <> */} + +

${(goals.featuredFunded.amountCents * (goals.featuredFunded.completedPercentage * 0.01) / 100)} of {goals.featuredFunded.amountCents / 100} ({goals.featuredFunded.completedPercentage}%) +

+
+ FUNDED +
+

{goals.featuredFunded.description}

+
+ + {/* the next unfunded goal */} +
+

${(goals.featuredUnfunded.amountCents * (goals.featuredUnfunded.completedPercentage * 0.01) / 100) | 0} of ${goals.featuredUnfunded.amountCents / 100} ({goals.featuredUnfunded.completedPercentage}%)

+ + {goals.featuredUnfunded.completedPercentage}% + +

{goals.featuredUnfunded.description}

+
+
+ +

+ Thank you, Patrons! +

+
+
+ + ); +}; + diff --git a/packages/next/app/components/icons/carrd.tsx b/packages/next/app/components/icons/carrd.tsx new file mode 100644 index 0000000..d900cda --- /dev/null +++ b/packages/next/app/components/icons/carrd.tsx @@ -0,0 +1,8 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + {"Carrd"} + + +) +export default SvgComponent diff --git a/packages/next/app/components/icons/chaturbate.tsx b/packages/next/app/components/icons/chaturbate.tsx new file mode 100644 index 0000000..31c641f --- /dev/null +++ b/packages/next/app/components/icons/chaturbate.tsx @@ -0,0 +1,14 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + + +) +export default SvgComponent diff --git a/packages/next/app/components/icons/fansly.tsx b/packages/next/app/components/icons/fansly.tsx new file mode 100644 index 0000000..03a78dc --- /dev/null +++ b/packages/next/app/components/icons/fansly.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + + + +) +export default SvgComponent diff --git a/packages/next/app/components/icons/linktree.tsx b/packages/next/app/components/icons/linktree.tsx new file mode 100644 index 0000000..3e17f8b --- /dev/null +++ b/packages/next/app/components/icons/linktree.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + + + + + + + + + + +) +export default SvgComponent diff --git a/packages/next/app/components/icons/onlyfans.tsx b/packages/next/app/components/icons/onlyfans.tsx new file mode 100644 index 0000000..81a568a --- /dev/null +++ b/packages/next/app/components/icons/onlyfans.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + + + + + +) +export default SvgComponent diff --git a/packages/next/app/components/icons/pornhub.tsx b/packages/next/app/components/icons/pornhub.tsx new file mode 100644 index 0000000..5f7a746 --- /dev/null +++ b/packages/next/app/components/icons/pornhub.tsx @@ -0,0 +1,23 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + + + + + + +) +export default SvgComponent diff --git a/packages/next/app/components/icons/throne.tsx b/packages/next/app/components/icons/throne.tsx new file mode 100644 index 0000000..897285c --- /dev/null +++ b/packages/next/app/components/icons/throne.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +const SvgComponent = (props: any) => ( + + + + + + + + + + + + +) +export default SvgComponent diff --git a/packages/next/app/components/ipfs-cid.tsx b/packages/next/app/components/ipfs-cid.tsx new file mode 100644 index 0000000..efbde66 --- /dev/null +++ b/packages/next/app/components/ipfs-cid.tsx @@ -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 ( +
+ {label} +
{cid}
+ {(isCopied) ? + + : + { + navigator.clipboard.writeText(cid) + setIsCopied(true) + setTimeout(() => setIsCopied(false), 3000) + }} + > + } +
+ ) +} diff --git a/packages/next/app/components/ipfs-logo.tsx b/packages/next/app/components/ipfs-logo.tsx new file mode 100644 index 0000000..3875418 --- /dev/null +++ b/packages/next/app/components/ipfs-logo.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface LogoProps { + size: number; + color: string; +} + +const IPFSLogo: React.FC = ({ size = 32, color = '#65C2CB' }) => { + + return ( + + IPFS + + + ); +}; + +export default IPFSLogo; \ No newline at end of file diff --git a/packages/next/app/components/ipfs.tsx b/packages/next/app/components/ipfs.tsx new file mode 100644 index 0000000..cacfea8 --- /dev/null +++ b/packages/next/app/components/ipfs.tsx @@ -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(null) +// const [helia, setHelia] = useState(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

Connecting to IPFS...

+// } + +// return ( +//
+//

ID: {id.toString()}

+//

Status: {isOnline ? 'Online' : 'Offline'}

+//
+// ) +// } + diff --git a/packages/next/app/components/linkable-heading.tsx b/packages/next/app/components/linkable-heading.tsx new file mode 100644 index 0000000..c5e782a --- /dev/null +++ b/packages/next/app/components/linkable-heading.tsx @@ -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 ( +

+ {icon && } + {text} + + + + +

+ ) +} \ No newline at end of file diff --git a/packages/next/app/components/localized-date.tsx b/packages/next/app/components/localized-date.tsx new file mode 100644 index 0000000..65ec1c5 --- /dev/null +++ b/packages/next/app/components/localized-date.tsx @@ -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 ( + <> + + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/navbar.tsx b/packages/next/app/components/navbar.tsx new file mode 100644 index 0000000..57fca00 --- /dev/null +++ b/packages/next/app/components/navbar.tsx @@ -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 ( + <> + + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/notification-center.tsx b/packages/next/app/components/notification-center.tsx new file mode 100644 index 0000000..31218c6 --- /dev/null +++ b/packages/next/app/components/notification-center.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + + +export default function NotificationCenter() { + return ( + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/notifications.tsx b/packages/next/app/components/notifications.tsx new file mode 100644 index 0000000..1968ecd --- /dev/null +++ b/packages/next/app/components/notifications.tsx @@ -0,0 +1,10 @@ + +export function DangerNotification ({ errors }: { errors: String[] }): JSX.Element { + return ( +
+ {errors && errors.map((error, index) => ( +

Error:{error}

+ ))} +
+ ); +} \ No newline at end of file diff --git a/packages/next/app/components/pager.tsx b/packages/next/app/components/pager.tsx new file mode 100644 index 0000000..01f67ab --- /dev/null +++ b/packages/next/app/components/pager.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/packages/next/app/components/patrons-list.tsx b/packages/next/app/components/patrons-list.tsx new file mode 100644 index 0000000..f54961e --- /dev/null +++ b/packages/next/app/components/patrons-list.tsx @@ -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 ( + + + + ); + if (displayStyle === 'box') { + return ( +
+ {patrons.map((patron) => ( +
+
+
+
+
+ {patron.username && ( + + {patron.username} + + )} + {patron.vanityLink && ( + + {patron.vanityLink} + + + + + )} +
+
+
+
+
+ ))} +
+ ); + } else if (displayStyle === 'list') { + const patronNames = patrons.map((patron) => patron.username.trim()).join(', '); + return {patronNames}; + } else { + return ; // Handle unsupported display styles or provide a default display style + } +} + diff --git a/packages/next/app/components/sortable-tags.tsx b/packages/next/app/components/sortable-tags.tsx new file mode 100644 index 0000000..669b02d --- /dev/null +++ b/packages/next/app/components/sortable-tags.tsx @@ -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 ( + <> +
+
+ setFilterText(e.target.value)} + /> + + + +
+
+
+ +
+
+
+
+ {sortedTags.map((tag: ITag) => ( + + + {tag.attributes.name} ({tag.attributes.count}) + + + ))} +
+ + ); +} diff --git a/packages/next/app/components/stream-button.tsx b/packages/next/app/components/stream-button.tsx new file mode 100644 index 0000000..5c20280 --- /dev/null +++ b/packages/next/app/components/stream-button.tsx @@ -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

{JSON.stringify(stream, null, 2)}

+ // return {new Date(stream.attributes.date).toLocaleDateString()} + + return ( + + {new Date(stream.attributes.date).toLocaleDateString()} + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/stream-page.tsx b/packages/next/app/components/stream-page.tsx new file mode 100644 index 0000000..7264b2b --- /dev/null +++ b/packages/next/app/components/stream-page.tsx @@ -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(determineStatus(stream)); + + const styleMap: Record = { + '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 + + // return

+ //

+    //         
+    //             {JSON.stringify(stream, null, 2)}
+
+    //         
+    //     
+ + //

+ // 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 ( + <> + + +
+
+

{displayName} Stream Archive

+
+ +
+
+
+

Details

+
+
+ Announcement 

+ Platform {platformsList}

+ UTC Datetime 

+ Local Datetime {date.toLocaleDateString()} {date.toLocaleTimeString()}

+ Lunar Phase {Moon.lunarPhase(date)} {Moon.lunarPhaseEmoji(date, { hemisphere })}

+

+ {/* */} +
+
+
+
+ + +
+
+
+ VOD {capitalizeFirstLetter(selectedStatus)} +
+
+ +

{desc1}

+

{desc2}
+ Upload it here.

+
+
+
+ +
+ + +
+

VODs

+ + + + + + {/* + */} + + + + + + + {stream.attributes.vods.data.map((vod: IVod) => ( + + {/*

{JSON.stringify(vod, null, 2)}

*/} + + + {/* + */} + + + + + ))} + +
IDUpload DateThumbnailDurationTagsTimestampsNote
{vod.attributes.cuid}{vod.attributes.publishedAt}{(!!vod?.attributes?.thumbnail?.data?.attributes?.cdnUrl) ? : }{(!!vod?.attributes?.duration) ? vod.attributes.duration : }{vod.attributes.tagVodRelations.data.length}{vod.attributes.timestamps.data.length}{(!!vod.attributes.note) ? : }
+
+ +
+ + + ) +} diff --git a/packages/next/app/components/stream.tsx b/packages/next/app/components/stream.tsx new file mode 100644 index 0000000..3e54a19 --- /dev/null +++ b/packages/next/app/components/stream.tsx @@ -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 + return ( +
+
+                
+                    {JSON.stringify(stream, null, 2)}
+                
+            
+ {/*

Stream {stream.attributes.date}

*/} +
+ ) +} + + + +export function StreamSummary ({ stream }: IStreamProps) { + if (!stream) return + + // return ( + //
+    //         
+    //             {JSON.stringify(stream, null, 2)}
+    //         
+    //     
+ // ) + + 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 ( + +
+ {/*
+                    
+                        {JSON.stringify(stream, null, 2)}
+                    
+                
*/} +
+ + {stream.attributes.vtuber.data.attributes.displayName} + +
+
+ {stream.attributes.vtuber.data.attributes.displayName} +
+
+ +
+
+ {(stream.attributes.isChaturbateStream) && } + {(stream.attributes.isFanslyStream) && } +
+
+
{stream.attributes.archiveStatus}
+
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/components/streams-calendar.tsx b/packages/next/app/components/streams-calendar.tsx new file mode 100644 index 0000000..fb04263 --- /dev/null +++ b/packages/next/app/components/streams-calendar.tsx @@ -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 ( + <> + { + handleEventClick(args, router); + }} + /> + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/streams-list.tsx b/packages/next/app/components/streams-list.tsx new file mode 100644 index 0000000..b70110a --- /dev/null +++ b/packages/next/app/components/streams-list.tsx @@ -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 ( +
+

+ {displayName} Streams +

+
+ ) +} + + +export default async function StreamsList({ vtubers, page = 1, pageSize = 24 }: IStreamsListProps): Promise { + if (!vtubers) return
vtubers is not defined. vtubers:{JSON.stringify(vtubers, null, 2)}
+ + // const streams = await getStreamsForVtuber(vtubers[0].id); + const streams = await getAllStreams(['missing', 'issue', 'good']); + + if (!streams) return notFound(); + + + // @todo [ ] pagination + // @todo [ ] sortability + return ( + <> + +

Stream Archive

+ + + ); +} diff --git a/packages/next/app/components/tag-button.tsx b/packages/next/app/components/tag-button.tsx new file mode 100644 index 0000000..dda1ea5 --- /dev/null +++ b/packages/next/app/components/tag-button.tsx @@ -0,0 +1,8 @@ + +import { useState } from 'react'; + +export function TagButton ({ name, selectedTag, setSelectedTag }: { name: string, selectedTag: string | null, setSelectedTag: Function }) { + return ( + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/tag.tsx b/packages/next/app/components/tag.tsx new file mode 100644 index 0000000..cc043dd --- /dev/null +++ b/packages/next/app/components/tag.tsx @@ -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 { + 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(false); + + useEffect(() => { + setShouldRenderDeleteButton(isCreatedByMeRecently(authContext?.authData?.user?.id, tvr)); + }, [authContext?.authData?.user?.id, tvr]); + + return ( + + {tvr.attributes.tag.data.attributes.name} + {shouldRenderDeleteButton && { + handleDelete(authContext, tvr); router.refresh() + } + } className="tag">} + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/tagger.tsx b/packages/next/app/components/tagger.tsx new file mode 100644 index 0000000..2918642 --- /dev/null +++ b/packages/next/app/components/tagger.tsx @@ -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({ + defaultValues: { + tagName: '', + isTimestamp: true + } + }); + const [isEditor, setIsEditor] = useState(false); + const [isAuthed, setIsAuthed] = useState(false); + const [tagSuggestions, setTagSuggestions] = useState([]); + 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 ( +
+ + +
+

Tagger

+ +
+
+
+
+ + +
+
+ Suggestions + {tagSuggestions.length > 0 && tagSuggestions.map((tag: ITagSuggestion) => ())} +
+
+ +
+ +
+ {(!!errors?.root?.serverError) &&
{errors.root.serverError.message}
} + + +
+
+
+
+ ) + } else { + return ( + + ); + } + } + + +} diff --git a/packages/next/app/components/timestamps-list.tsx b/packages/next/app/components/timestamps-list.tsx new file mode 100644 index 0000000..e2ad0a0 --- /dev/null +++ b/packages/next/app/components/timestamps-list.tsx @@ -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 ( +
+ + + {hasTimestamps && ( + timestamps.map((ts: ITimestamp) => ( +

+ {/* {JSON.stringify(ts, null, 2)}


*/} + + {formatTimestamp(ts.attributes.time)} + {' '} + {ts.attributes.tag.data.attributes.name} + {authContext?.authData && isCreatedByMeRecently(authContext.authData, ts) && ( + + )} +

+ )) + )} + + {!hasTimestamps &&

This VOD has no timestamps

} +
+ ); +} diff --git a/packages/next/app/components/toys.tsx b/packages/next/app/components/toys.tsx new file mode 100644 index 0000000..564d0ef --- /dev/null +++ b/packages/next/app/components/toys.tsx @@ -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 ( +
+

+ {displayName}'s Toys +

+
+ ) +} + +// 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
toy.linkTag is missing which is a problem
+ return ( +
+ + +
+ {displayName} +
+

{toy.attributes.model}

+ +
+ ); +}; + +export function ToysList({ vtuber, toys, page = 1, pageSize = 24 }: IToysListsProps) { + return ( +
+ {/*
{JSON.stringify(toys, null, 2)} toys:{toys.data.length} page:{page} pageSize:{pageSize}
*/} +
+ {toys.data.map((toy: IToy) => ( + //

{JSON.stringify(toy, null, 2)}

+ + ))} +
+
+ ) +}; diff --git a/packages/next/app/components/upload-form.tsx b/packages/next/app/components/upload-form.tsx new file mode 100644 index 0000000..9283822 --- /dev/null +++ b/packages/next/app/components/upload-form.tsx @@ -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 { }; + + +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 ( + <> + +
+

Upload VOD

+ +

Together we can archive all lewdtuber livestreams!

+ + {(!authData?.accessToken) + ? + <> + + + + : ( + + + +
+
createUSC(data))}> + + +
+
+
+

+ Step 1 +

+

+ Upload the file +

+
+
+
+ + + + {errors.files &&

{errors.files.message?.toString()}

} + +
+
+ +
+ {/* {(!cuid) && } */} + +
+
+

+ Step 2 +

+

+ Tell us about the VOD +

+
+
+ +
+ + + + + + +
+ +
+ +
+

Choose the VTuber this VOD belongs to. (More VTubers will be added when storage/bandwidth funding is secured.)

+ {errors.vtuber &&

vtuber error

} + +
+ +
+ + setDate(evt.target.value)} + > +

The date when the VOD was originally streamed.

+ {errors.date &&

{errors.date.message?.toString()}

} + +
+ +
+ + +

If there are any issues with the VOD, put a note here. If there are no VOD issues, leave this field blank.

+
+ +
+ + +
+ +
+ +
+ + +
+
+
+

+ Step 3 +

+

+ Send the form +

+
+
+
+ + + +
+ + + + Step 1, File Upload +
+ +
+ + + + Step 2, Metadata +
+ + + + {/*

{message}

} + /> */} + + {/* {fields.map((field, index) => ( +
+ {' '} + +
+ ))} */} + + {/* { + JSON.stringify({ + touchedFields: Object.keys(touchedFields), + errors: Object.keys(errors) + }, null, 2) + } */} + + {/* setError('date', { type: 'custom', message: 'custom message' }); */} + + + + + + +
+
+ +
+
+ + + ) + } + +
+ + + ) + +} diff --git a/packages/next/app/components/user-controls.tsx b/packages/next/app/components/user-controls.tsx new file mode 100644 index 0000000..8fa4b8a --- /dev/null +++ b/packages/next/app/components/user-controls.tsx @@ -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

Loading...

+ + + const hasUrlBenefit = (authData?.user?.patreonBenefits) ? authData.user.patreonBenefits.split(' ').includes(patreonQuantumSupporterId) : false; + + return ( +
+
+

Patron Perks

+ + + + + + +
+
+ ); +}; + + +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 ( + + ) +} + +export function Thanks() { + return

Thank you so much for supporting Futureporn!

+} + +export function QuantumSupporterPerks({ isLinkPublic, setIsLinkPublic, setVanityLink, vanityLink, hasUrlBenefit }: IQuantumSupporterProps) { + const { authData } = useAuth() + + return ( +
+ +
+ +
+
+ setVanityLink(e.target.value)} + /> +
+ +
+ ) +} + +export function AdvancedArchiveSupporterPerks() { + +} + +export function ArchiveSupporterPerks({ isNamePublic, setIsNamePublic }: IArchiveSupporterProps) { + const { authData } = useAuth() + + return ( +
+ +
+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/next/app/components/video-context.tsx b/packages/next/app/components/video-context.tsx new file mode 100644 index 0000000..4281a25 --- /dev/null +++ b/packages/next/app/components/video-context.tsx @@ -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({} as IVideoContextValue); + + +// export function VideoContextProvider({ children }: IAuthContextProps): React.JSX.Element { +// const { value: authData, set: setAuthData } = useLocalStorageValue('authData', { +// defaultValue: null, +// }); + +// const { value: lastVisitedPath, set: setLastVisitedPath } = useLocalStorageValue('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 ( +// +// {children} +// +// ); +// } \ No newline at end of file diff --git a/packages/next/app/components/video-interactive.tsx b/packages/next/app/components/video-interactive.tsx new file mode 100644 index 0000000..28617d3 --- /dev/null +++ b/packages/next/app/components/video-interactive.tsx @@ -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([]); + 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 ( + + + +

+ {getVodTitle(vod)} +

+ + +
+ {vod.attributes.note && ( + <> + +
{vod.attributes.note}
+ + )} + + + +
+ {vod.attributes.tagVodRelations.data.length === 0 &&

This vod has no tags

} + {vod.attributes.tagVodRelations.data.map((tvr: ITagVodRelation) => ( + + ))} + +
+ + +
+ +
+ ) +} \ No newline at end of file diff --git a/packages/next/app/components/video-player.tsx b/packages/next/app/components/video-player.tsx new file mode 100644 index 0000000..25bd69d --- /dev/null +++ b/packages/next/app/components/video-player.tsx @@ -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 { + 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 ): 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(''); + 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 ( + <> + { + 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 + > + + + + ) +}) \ No newline at end of file diff --git a/packages/next/app/components/video-source-selector.tsx b/packages/next/app/components/video-source-selector.tsx new file mode 100644 index 0000000..5f011a9 --- /dev/null +++ b/packages/next/app/components/video-source-selector.tsx @@ -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 ( + <> +
+ +
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/components/vod-card.tsx b/packages/next/app/components/vod-card.tsx new file mode 100644 index 0000000..175ed40 --- /dev/null +++ b/packages/next/app/components/vod-card.tsx @@ -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

VOD {id} is missing VTuber

+ + return ( +
+
+ +
+
+ {title} +
+
+
+

{title}

+ + +
+
+ +
+ {muxAsset && ( +
+ +
+ )} +
+
+ +
+
+ + ) + } + + + + + diff --git a/packages/next/app/components/vod-nav.tsx b/packages/next/app/components/vod-nav.tsx new file mode 100644 index 0000000..c760ed1 --- /dev/null +++ b/packages/next/app/components/vod-nav.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/packages/next/app/components/vod-page.tsx b/packages/next/app/components/vod-page.tsx new file mode 100644 index 0000000..9d81faa --- /dev/null +++ b/packages/next/app/components/vod-page.tsx @@ -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 ( + +
+
+
+ + + {(vod.attributes.videoSrcHash || vod.attributes.video240Hash) && ( + <> + + {vod.attributes.videoSrcHash && ( + + )} + {vod.attributes.video240Hash && ( + + )} + + )} + + + + + +
+
+
+ ); +} diff --git a/packages/next/app/components/vods-list.tsx b/packages/next/app/components/vods-list.tsx new file mode 100644 index 0000000..1eb8b96 --- /dev/null +++ b/packages/next/app/components/vods-list.tsx @@ -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 ( +
+

+ {displayName} Vods +

+
+ ) +} + + +export default function VodsList({ vods, page = 1, pageSize = 24 }: IVodsListProps): React.JSX.Element { + // if (!vtuber) return
vtuber is not defined. vtuber:{JSON.stringify(vtuber, null, 2)}
+ // if (!vods) return
failed to load vods
; + if (!vods) return notFound() + + // @todo [x] pagination + // @todo [x] sortability + return ( + <> + {/*

VodsList on page {page}, pageSize {pageSize}, with {vods.data.length} vods

*/} + + {/*
+                
+                    {JSON.stringify(vods.data, null, 2)}
+                
+            
*/} + + +
+ {vods.map((vod: IVod) => ( + + ))} +
+ + ); +} diff --git a/packages/next/app/components/vtuber-button.tsx b/packages/next/app/components/vtuber-button.tsx new file mode 100644 index 0000000..6826a28 --- /dev/null +++ b/packages/next/app/components/vtuber-button.tsx @@ -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 ( +
+ + {displayName} + + {displayName} +
+ ); +} \ No newline at end of file diff --git a/packages/next/app/components/vtuber-card.tsx b/packages/next/app/components/vtuber-card.tsx new file mode 100644 index 0000000..a324dc2 --- /dev/null +++ b/packages/next/app/components/vtuber-card.tsx @@ -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

this is a vtubercard with an invalid imageBlur={imageBlur}

+ const vods = await getVodsForVtuber(id) + if (!vods) return + return ( + +
+
+
+
+
+ {displayName} +
+
+
+

{displayName}

+ +
+
+
+
+ + ) + } \ No newline at end of file diff --git a/packages/next/app/connect/patreon/redirect/page.tsx b/packages/next/app/connect/patreon/redirect/page.tsx new file mode 100644 index 0000000..382ad44 --- /dev/null +++ b/packages/next/app/connect/patreon/redirect/page.tsx @@ -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([]) + + 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 => { + + 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 ( +
+ {errors && errors.length > 0 && ( + + )} +

Redirecting...

+ Click here if you are not automatically redirected +
+ ) +} \ No newline at end of file diff --git a/packages/next/app/faq/page.tsx b/packages/next/app/faq/page.tsx new file mode 100644 index 0000000..6caeca4 --- /dev/null +++ b/packages/next/app/faq/page.tsx @@ -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 ( +
+
+
+

Frequently Asked Questions (FAQ)

+ + + +
+ +

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.

+
+ +
+ +

Lewdtubers are sexually explicit vtubers. ProjektMelody was the first Vtuber to livestream on Chaturbate on {projektMelodyEpoch.toDateString()}. Many more followed after her.

+
+ +
+ +

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.

+

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.

+

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 https://ipfs.io/ipfs/bafkreigaknpexyvxt76zgkitavbwx6ejgfheup5oybpm77f3pxzrvwpfdi

+

The next way to use IPFS consists of running IPFS Desktop 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 IPFS Companion, or a web browser that natively supports IPFS, such as Brave browser.

+
+ + + +
+
+
+ +
+
+

You may get an error when clicking on a video link. Errors such as DNS_PROBE_FINISHED_NXDOMAIN

+ +

This is a DNS server error that occurs when a web browser isn't able to translate the domain name into an IP address.

+ +

If this happens, using a different DNS server can fix it. There are many gratis services to choose from, including Cloudflare DNS or Google DNS.

+ +

Often, using a DNS server other than the one provided to you by your ISP can improve your internet browsing experience for all websites.

+
+
+
+ +
+
+
+ +
+ +
+

Bandwidth is prohibitively expensive, so that's the free-to-play experience at the moment. (Patrons get access to CDN which is much faster.)

+

If the video isn't loading fast enough to stream, you can download the entire video then playback later on your device.

+
+
+
+ +
+
+
+ + +

Yes! The recommended way is to use either IPFS Desktop or ipget.

+

ipget example is as follows.

+
+                  
+                    ipget --progress -o projektmelody-chaturbate-2023-12-03.mp4 bafybeiejms45zzonfe7ndr3mp4vmrqrg3btgmuche3xkeq5b77uauuaxkm
+                  
+                
+
+
+
+ +
+
+ +
+

Yes. Futureporn aims to become the galaxy's best VTuber hentai site.

+
+ +
+
+ +

Bandwidth and rental fees are expensive, so Futureporn needs financial assistance to keep servers online and videos streaming.

+

Patrons gain access to perks like our video Content Delivery Network (CDN), and optional shoutouts on the patrons page.

+

Additionally, help is needed populating our archive with vods from past lewdtuber streams.

+
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/next/app/favicon.ico b/packages/next/app/favicon.ico new file mode 100644 index 0000000..2ed11c7 Binary files /dev/null and b/packages/next/app/favicon.ico differ diff --git a/packages/next/app/feed/feed.json/route.ts b/packages/next/app/feed/feed.json/route.ts new file mode 100644 index 0000000..f7b17ca --- /dev/null +++ b/packages/next/app/feed/feed.json/route.ts @@ -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) +} \ No newline at end of file diff --git a/packages/next/app/feed/feed.xml/route.ts b/packages/next/app/feed/feed.xml/route.ts new file mode 100644 index 0000000..16a53bd --- /dev/null +++ b/packages/next/app/feed/feed.xml/route.ts @@ -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) +} \ No newline at end of file diff --git a/packages/next/app/feed/page.tsx b/packages/next/app/feed/page.tsx new file mode 100644 index 0000000..e87e056 --- /dev/null +++ b/packages/next/app/feed/page.tsx @@ -0,0 +1,28 @@ + +import Link from 'next/link' + +export default async function Page() { + return ( + <> +
+
+
+ +

RSS Feed

+ +

Keep up to date with new VODs using Real Simple Syndication (RSS).

+ +

Don't have a RSS reader? Futureporn recommends Fraidycat

+ +
+

ATOM

+

RSS

+

JSON

+
+
+
+
+ + ) +} + diff --git a/packages/next/app/feed/rss.xml/route.ts b/packages/next/app/feed/rss.xml/route.ts new file mode 100644 index 0000000..f8b4747 --- /dev/null +++ b/packages/next/app/feed/rss.xml/route.ts @@ -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) +} \ No newline at end of file diff --git a/packages/next/app/goals/page.tsx b/packages/next/app/goals/page.tsx new file mode 100644 index 0000000..4bf4f05 --- /dev/null +++ b/packages/next/app/goals/page.tsx @@ -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 Funded; + return ( + + + {completedPercentage}% Funded + + + ); +} + + + +// 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

failed to get goals

+ const { inProgress, planned, complete } = goals; + return ( + <> +
+
+
+ +

Goals

+

+ In Progress +

+
    + {inProgress.map((goal) => ( +
  • + ☐ {goal.title} {(!!goal?.amountCents && !!goal.completedPercentage) && } +
  • + ))} +
+

+ Planned +

+
    + {planned.map((goal) => ( +
  • + ☐ {goal.title} {(!!goal?.amountCents && !!goal.completedPercentage) && } +
  • + ))} +
+

+ Completed +

+
    + {complete.map((goal) => ( +
  • + ✅ {goal.title} {(!!goal?.amountCents && !!goal.completedPercentage) && } +
  • + ))} +
+
+
+
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/health/page.tsx b/packages/next/app/health/page.tsx new file mode 100644 index 0000000..657976e --- /dev/null +++ b/packages/next/app/health/page.tsx @@ -0,0 +1,12 @@ +import Tes from '@/assets/svg/tes'; + +export default async function Page() { + return ( +
+
+

Healthy!

+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/next/app/latest-vods/[page]/page.tsx b/packages/next/app/latest-vods/[page]/page.tsx new file mode 100644 index 0000000..1c03ece --- /dev/null +++ b/packages/next/app/latest-vods/[page]/page.tsx @@ -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
Error: {JSON.stringify(error)}
; + } + + return ( + <> +

Latest VODs

+

page {page}

+ + + + ); +} diff --git a/packages/next/app/latest-vods/page.tsx b/packages/next/app/latest-vods/page.tsx new file mode 100644 index 0000000..2c1edc7 --- /dev/null +++ b/packages/next/app/latest-vods/page.tsx @@ -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 ( + <> +

Latest VODs

+

page 1

+ + + + ) +} \ No newline at end of file diff --git a/packages/next/app/layout.tsx b/packages/next/app/layout.tsx new file mode 100644 index 0000000..26d3b81 --- /dev/null +++ b/packages/next/app/layout.tsx @@ -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 ( + + + {/* */} + + + + +
+ {children} +
+
+
+
+ + + ) +} diff --git a/packages/next/app/lib/b2File.ts b/packages/next/app/lib/b2File.ts new file mode 100644 index 0000000..876f3a8 --- /dev/null +++ b/packages/next/app/lib/b2File.ts @@ -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; +} \ No newline at end of file diff --git a/packages/next/app/lib/blog.ts b/packages/next/app/lib/blog.ts new file mode 100644 index 0000000..222b7f6 --- /dev/null +++ b/packages/next/app/lib/blog.ts @@ -0,0 +1,5 @@ +export interface IBlogPost { + slug: string; + title: string; + id: number; +} \ No newline at end of file diff --git a/packages/next/app/lib/constants.ts b/packages/next/app/lib/constants.ts new file mode 100644 index 0000000..174bccd --- /dev/null +++ b/packages/next/app/lib/constants.ts @@ -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') \ No newline at end of file diff --git a/packages/next/app/lib/contributors.ts b/packages/next/app/lib/contributors.ts new file mode 100644 index 0000000..e2e0788 --- /dev/null +++ b/packages/next/app/lib/contributors.ts @@ -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 { + try { + const res = await fetchAPI(`/contributors`); + return res.data; + } catch (e) { + console.error(`error while fetching contributors`) + console.error(e); + return null; + } +} diff --git a/packages/next/app/lib/dates.ts b/packages/next/app/lib/dates.ts new file mode 100644 index 0000000..5d7c8ca --- /dev/null +++ b/packages/next/app/lib/dates.ts @@ -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; +} \ No newline at end of file diff --git a/packages/next/app/lib/fetch-api.ts b/packages/next/app/lib/fetch-api.ts new file mode 100644 index 0000000..1130079 --- /dev/null +++ b/packages/next/app/lib/fetch-api.ts @@ -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.`); + } +} \ No newline at end of file diff --git a/packages/next/app/lib/fetchers.ts b/packages/next/app/lib/fetchers.ts new file mode 100644 index 0000000..3e28468 --- /dev/null +++ b/packages/next/app/lib/fetchers.ts @@ -0,0 +1,32 @@ +import { strapiUrl } from "./constants"; + +export async function fetchPaginatedData(apiEndpoint: string, pageSize: number, queryParams: Record = {}): Promise { + 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; +} diff --git a/packages/next/app/lib/ipfs.ts b/packages/next/app/lib/ipfs.ts new file mode 100644 index 0000000..ffb3f04 --- /dev/null +++ b/packages/next/app/lib/ipfs.ts @@ -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}` +} diff --git a/packages/next/app/lib/patreon.ts b/packages/next/app/lib/patreon.ts new file mode 100644 index 0000000..420356c --- /dev/null +++ b/packages/next/app/lib/patreon.ts @@ -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 { + const res = await fetch(`${strapiUrl}/api/patreon/patrons`); + return res.json(); +} + + +export async function getCampaign(): Promise { + 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 +} diff --git a/packages/next/app/lib/pm.ts b/packages/next/app/lib/pm.ts new file mode 100644 index 0000000..9eb3183 --- /dev/null +++ b/packages/next/app/lib/pm.ts @@ -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 { + 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 { + 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) + }; +} diff --git a/packages/next/app/lib/retry.ts b/packages/next/app/lib/retry.ts new file mode 100644 index 0000000..68a4d7d --- /dev/null +++ b/packages/next/app/lib/retry.ts @@ -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; +} \ No newline at end of file diff --git a/packages/next/app/lib/rss.ts b/packages/next/app/lib/rss.ts new file mode 100644 index 0000000..121d43b --- /dev/null +++ b/packages/next/app/lib/rss.ts @@ -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() + } +} + + diff --git a/packages/next/app/lib/shareRef.ts b/packages/next/app/lib/shareRef.ts new file mode 100644 index 0000000..5a6dddc --- /dev/null +++ b/packages/next/app/lib/shareRef.ts @@ -0,0 +1,16 @@ +import type { MutableRefObject, RefCallback } from 'react'; + +type RefType = MutableRefObject | RefCallback | null; + +export const shareRef = (refA: RefType, refB: RefType): RefCallback => instance => { + if (typeof refA === 'function') { + refA(instance); + } else if (refA && 'current' in refA) { + (refA as MutableRefObject).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).current = instance as T; // Use type assertion to tell TypeScript the type + } +}; diff --git a/packages/next/app/lib/streams.ts b/packages/next/app/lib/streams.ts new file mode 100644 index 0000000..1a45abf --- /dev/null +++ b/packages/next/app/lib/streams.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +// } +// } \ No newline at end of file diff --git a/packages/next/app/lib/tag-vod-relations.ts b/packages/next/app/lib/tag-vod-relations.ts new file mode 100644 index 0000000..71d6e52 --- /dev/null +++ b/packages/next/app/lib/tag-vod-relations.ts @@ -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 { + 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 { + 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 { + 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 { + // 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; +} + diff --git a/packages/next/app/lib/tags.ts b/packages/next/app/lib/tags.ts new file mode 100644 index 0000000..ee5385d --- /dev/null +++ b/packages/next/app/lib/tags.ts @@ -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 { + 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 { + + 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 { + 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 { + 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(); + + // 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; +} diff --git a/packages/next/app/lib/timestamps.ts b/packages/next/app/lib/timestamps.ts new file mode 100644 index 0000000..e80cf54 --- /dev/null +++ b/packages/next/app/lib/timestamps.ts @@ -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 { + 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 { + 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; +} \ No newline at end of file diff --git a/packages/next/app/lib/toys.ts b/packages/next/app/lib/toys.ts new file mode 100644 index 0000000..dee0e7b --- /dev/null +++ b/packages/next/app/lib/toys.ts @@ -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 { +// const tvrs = await getTagVodRelationsForVtuber(vtuberId, page, pageNumber); +// return { +// data: tvrs.data. +// pagination: tvrs.pagination +// } +// } diff --git a/packages/next/app/lib/tweets.ts b/packages/next/app/lib/tweets.ts new file mode 100644 index 0000000..caf9607 --- /dev/null +++ b/packages/next/app/lib/tweets.ts @@ -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; +} + diff --git a/packages/next/app/lib/types.ts b/packages/next/app/lib/types.ts new file mode 100644 index 0000000..8a2cc29 --- /dev/null +++ b/packages/next/app/lib/types.ts @@ -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; +} diff --git a/packages/next/app/lib/useForwardRef.ts b/packages/next/app/lib/useForwardRef.ts new file mode 100644 index 0000000..7220d13 --- /dev/null +++ b/packages/next/app/lib/useForwardRef.ts @@ -0,0 +1,27 @@ +/** + * greetz https://github.com/facebook/react/issues/24722#issue-1270749463 + */ + +import React, { useEffect, useRef, type ForwardedRef } from 'react'; + +const useForwardRef = ( + ref: ForwardedRef, + initialValue: any = null +) => { + const targetRef = useRef(initialValue); + + useEffect(() => { + if (!ref) return; + + if (typeof ref === 'function') { + ref(targetRef.current); + } else { + ref.current = targetRef.current; + } + }, [ref]); + + return targetRef; +}; + + +export default useForwardRef \ No newline at end of file diff --git a/packages/next/app/lib/users.ts b/packages/next/app/lib/users.ts new file mode 100644 index 0000000..c3fa3ed --- /dev/null +++ b/packages/next/app/lib/users.ts @@ -0,0 +1,16 @@ +import { IMeta } from "./types"; + + +export interface IUser { + id: number; + attributes: { + username: string; + vanityLink?: string; + image: string; + } +} + +export interface IUserResponse { + data: IUser; + meta: IMeta; +} \ No newline at end of file diff --git a/packages/next/app/lib/vods.ts b/packages/next/app/lib/vods.ts new file mode 100644 index 0000000..b4ad2ac --- /dev/null +++ b/packages/next/app/lib/vods.ts @@ -0,0 +1,502 @@ + +import { strapiUrl, siteUrl } from './constants'; +import { getDateFromSafeDate, getSafeDate } from './dates'; +import { IVtuber, IVtuberResponse } from './vtubers'; +import { IStream, IStreamResponse } from './streams'; +import qs from 'qs'; +import { ITagVodRelationsResponse } from './tag-vod-relations'; +import { ITimestampsResponse } from './timestamps'; +import { IMeta, IMuxAsset, IMuxAssetResponse } from './types'; +import { IB2File, IB2FileResponse } from '@/lib/b2File'; +import fetchAPI from './fetch-api'; +import { IUserResponse } from './users'; + +/** + * Dec 2023 CUIDs were introduced. + * Going forward, use CUIDs where possible. + * safeDates are retained for backwards compatibility. + * + * @see https://www.w3.org/Provider/Style/URI + */ +export interface IVodPageProps { + params: { + safeDateOrCuid: string; + slug: string; + }; +} + +export interface IVodsResponse { + data: IVod[]; + meta: IMeta; +} + +export interface IVodResponse { + data: IVod; + meta: IMeta; +} + +export interface IVod { + id: number; + attributes: { + stream: IStreamResponse; + publishedAt?: string; + cuid: string; + title?: string; + duration?: number; + date: string; + date2: string; + muxAsset: IMuxAssetResponse; + thumbnail?: IB2FileResponse; + vtuber: IVtuberResponse; + tagVodRelations: ITagVodRelationsResponse; + timestamps: ITimestampsResponse; + video240Hash: string; + videoSrcHash: string; + videoSrcB2: IB2FileResponse | null; + announceTitle: string; + announceUrl: string; + uploader: IUserResponse; + note: string; + } +} + +const fetchVodsOptions = { + next: { + tags: ['vods'] + } +} + + +export async function getVodFromSafeDateOrCuid(safeDateOrCuid: string): Promise { + let vod: IVod|null; + let date: Date; + if (!safeDateOrCuid) { + console.log(`safeDateOrCuid was missing`); + return null; + } else if (/^[0-9a-z]{10}$/.test(safeDateOrCuid)) { + console.log('this is a CUID!'); + vod = await getVodByCuid(safeDateOrCuid); + if (!vod) return null; + } else { + console.log('This is a safeDate!'); + date = await getDateFromSafeDate(safeDateOrCuid); + if (!date) { + console.log('there is no date') + return null; + } else { + console.log(`date=${date}`) + } + vod = await getVodForDate(date); + } + return vod; +} + +export function getUrl(vod: IVod, slug: string, date: string): string { + return `${siteUrl}/vt/${slug}/vod/${getSafeDate(date)}` +} + + +export function getPaginatedUrl(): (slug: string, pageNumber: number) => string { + return (slug: string, pageNumber: number) => { + return `${siteUrl}/vt/${slug}/vods/${pageNumber}` + } +} + + +/** @deprecated old format for futureporn.net/api/v1.json, which is deprecated. Please use getUrl() instead */ +export function getDeprecatedUrl(vod: IVod): string { + return `${siteUrl}/vods/${getSafeDate(vod.attributes.date2)}` +} + +export async function getNextVod(vod: IVod): Promise { + const query = qs.stringify({ + filters: { + date2: { + $gt: vod.attributes.date2 + }, + vtuber: { + slug: { + $eq: vod.attributes.vtuber.data.attributes.slug + } + }, + publishedAt: { + $notNull: true + } + }, + sort: { + date2: 'asc' + }, + fields: ['date2', 'title', 'announceTitle'], + populate: { + vtuber: { + fields: ['slug'] + } + } + }) + const res = await fetch(`${strapiUrl}/api/vods?${query}`, fetchVodsOptions); + if (!res.ok) throw new Error('could not fetch next vod'); + const json = await res.json(); + const nextVod = json.data[0]; + if (!nextVod) return null; + return nextVod + +} + +export function getLocalizedDate(vod: IVod): string { + return new Date(vod.attributes.date2).toLocaleDateString() +} + +export async function getPreviousVod(vod: IVod): Promise { + const res = await fetchAPI( + '/vods', + { + filters: { + date2: { + $lt: vod.attributes.date2 + }, + vtuber: { + slug: { + $eq: vod.attributes.vtuber.data.attributes.slug + } + } + }, + sort: { + date2: 'desc' + }, + fields: ['date2', 'title', 'announceTitle'], + populate: { + vtuber: { + fields: ['slug'] + } + }, + pagination: { + limit: 1 + } + }, + fetchVodsOptions + ) + return res.data[0]; +} + +export async function getVodByCuid(cuid: string): Promise { + const query = qs.stringify( + { + filters: { + cuid: { + $eq: cuid + } + }, + populate: { + vtuber: { + fields: ['slug', 'displayName', 'image', 'imageBlur', 'themeColor'] + }, + muxAsset: { + fields: ['playbackId', 'assetId'] + }, + thumbnail: { + fields: ['cdnUrl', 'url'] + }, + tagVodRelations: { + fields: ['tag', 'createdAt', 'creatorId'], + populate: ['tag'] + }, + videoSrcB2: { + fields: ['url', 'key', 'uploadId', 'cdnUrl'] + }, + stream: { + fields: ['archiveStatus', 'date', 'tweet', 'cuid'] + } + } + }) + + try { + const res = await fetch(`${strapiUrl}/api/vods?${query}`, { cache: 'no-store', next: { tags: ['vods'] } }) + if (!res.ok) { + throw new Error('failed to fetch vodForDate') + } + const json = await res.json() + const vod = json.data[0] + if (!vod) return null; + return vod; + } catch (e) { + if (e instanceof Error) { + console.error(e) + } + return null; + } +} + +export async function getVodForDate(date: Date): Promise { + // if (!date) return null; + // console.log(date) + // console.log(`getting vod for ${date.toISOString()}`) + try { + const iso8601DateString = date.toISOString().split('T')[0]; + const query = qs.stringify( + { + filters: { + date2: { + $eq: date.toISOString() + } + }, + populate: { + vtuber: { + fields: ['slug', 'displayName', 'image', 'imageBlur', 'themeColor'] + }, + muxAsset: { + fields: ['playbackId', 'assetId'] + }, + thumbnail: { + fields: ['cdnUrl', 'url'] + }, + tagVodRelations: { + fields: ['tag', 'createdAt', 'creatorId'], + populate: ['tag'] + }, + videoSrcB2: { + fields: ['url', 'key', 'uploadId', 'cdnUrl'] + }, + stream: { + fields: ['archiveStatus', 'date', 'tweet', 'cuid'] + } + } + } + ) + const res = await fetch(`${strapiUrl}/api/vods?${query}`, { cache: 'no-store', next: { tags: ['vods'] } }) + if (!res.ok) { + throw new Error('failed to fetch vodForDate') + } + const json = await res.json() + const vod = json.data[0] + if (!vod) return null; + return vod; + } catch (e) { + + return null; + } +} + +export async function getVod(id: number): Promise { + const query = qs.stringify( + { + filters: { + id: { + $eq: id + } + } + } + ) + const res = await fetch(`${strapiUrl}/api/vods?${query}`, fetchVodsOptions); + if (!res.ok) return null; + const data = await res.json(); + return data; +} + +export async function getVods(page: number = 1, pageSize: number = 25, sortDesc = true): Promise { + const query = qs.stringify( + { + populate: { + vtuber: { + fields: ['slug', 'displayName', 'image', 'imageBlur'] + }, + muxAsset: { + fields: ['playbackId', 'assetId'] + }, + thumbnail: { + fields: ['cdnUrl', 'url'] + }, + tagVodRelations: { + fields: ['tag'], + populate: ['tag'] + }, + videoSrcB2: { + fields: ['url', 'key', 'uploadId', 'cdnUrl'] + } + }, + sort: { + date: (sortDesc) ? 'desc' : 'asc' + }, + pagination: { + pageSize: pageSize, + page: page + } + } + ) + + const res = await fetch(`${strapiUrl}/api/vods?${query}`, fetchVodsOptions); + if (!res.ok) { + throw new Error('Failed to fetch vods'); + } + const json = await res.json() + return json; +} + + + +export async function getAllVods(): Promise { + const pageSize = 100; // Adjust this value as needed + const sortDesc = true; // Adjust the sorting direction as needed + + const allVods: IVod[] = []; + let currentPage = 1; + + while (true) { + const query = qs.stringify({ + populate: { + vtuber: { + fields: ['slug', 'displayName', 'image', 'imageBlur'], + }, + muxAsset: { + fields: ['playbackId', 'assetId'], + }, + thumbnail: { + fields: ['cdnUrl', 'url'], + }, + tagVodRelations: { + fields: ['tag'], + populate: ['tag'], + }, + videoSrcB2: { + fields: ['url', 'key', 'uploadId', 'cdnUrl'], + }, + }, + sort: { + date: sortDesc ? 'desc' : 'asc', + }, + pagination: { + pageSize, + page: currentPage, + }, + }); + + try { + const response = await fetch(`${strapiUrl}/api/vods?${query}`, fetchVodsOptions); + + if (!response.ok) { + // Handle non-successful response (e.g., HTTP error) + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const responseData = await response.json(); + + if (!responseData.data || responseData.data.length === 0) { + // No more data to fetch + break; + } + + allVods.push(...responseData.data); + currentPage++; + } catch (error) { + // Handle fetch error + if (error instanceof Error) { + console.error('Error fetching data:', error.message); + } + return null; + } + } + + return allVods; +} + + + +export async function getVodsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25, sortDesc = true): Promise { + const query = qs.stringify( + { + populate: { + thumbnail: { + fields: ['cdnUrl', 'url'] + }, + vtuber: { + fields: [ + 'id', + 'slug', + 'displayName', + 'image', + 'imageBlur' + ] + }, + videoSrcB2: { + fields: ['url', 'key', 'uploadId', 'cdnUrl'] + } + }, + filters: { + vtuber: { + id: { + $eq: vtuberId + } + } + }, + pagination: { + page: page, + pageSize: pageSize + }, + sort: { + date: (sortDesc) ? 'desc' : 'asc' + } + } + ) + const res = await fetch(`${strapiUrl}/api/vods?${query}`, fetchVodsOptions) + if (!res.ok) return null; + const data = await res.json() as IVodsResponse; + return data; + +} + + +export async function getVodsForTag(tag: string): Promise { + const query = qs.stringify( + { + populate: { + vtuber: { + fields: ['slug', 'displayName', 'image', 'imageBlur'] + }, + videoSrcB2: { + fields: ['url', 'key', 'uploadId', 'cdnUrl'] + } + }, + filters: { + tagVodRelations: { + tag: { + name: { + $eq: tag + } + } + } + } + } + ) + const res = await fetch(`${strapiUrl}/api/vods?${query}`, fetchVodsOptions) + if (!res.ok) return null; + const vods = await res.json() + return vods; +} + +/** + * This returns stale data, because futureporn-historian is broken. + * @todo get live data from historian + * @see https://git.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/vods?${query}`, fetchVodsOptions) + .then((res) => res.json()) + .then((g) => { + return g + }) + + const total = data.meta.pagination.total + + return { + complete: total, + total: total + } +} \ No newline at end of file diff --git a/packages/next/app/lib/vtubers.ts b/packages/next/app/lib/vtubers.ts new file mode 100644 index 0000000..d671ee3 --- /dev/null +++ b/packages/next/app/lib/vtubers.ts @@ -0,0 +1,171 @@ + + +import { IVod } from './vods' +import { strapiUrl, siteUrl } from './constants'; +import { getSafeDate } from './dates'; +import qs from 'qs'; +import { resourceLimits } from 'worker_threads'; +import { IMeta } from './types'; + + +const fetchVtubersOptions = { + next: { + tags: ['vtubers'] + } +} + + +export interface IVtuber { + id: number; + attributes: { + slug: string; + displayName: string; + chaturbate?: string; + twitter?: string; + patreon?: string; + twitch?: string; + tiktok?: string; + onlyfans?: string; + youtube?: string; + linktree?: string; + carrd?: string; + fansly?: string; + pornhub?: string; + discord?: string; + reddit?: string; + throne?: string; + instagram?: string; + facebook?: string; + merch?: string; + vods: IVod[]; + description1: string; + description2?: string; + image: string; + imageBlur?: string; + themeColor: string; + } +} + +export interface IVtuberResponse { + data: IVtuber; + meta: IMeta; +} + +export interface IVtubersResponse { + data: IVtuber[]; + meta: IMeta; +} + + +export function getUrl(slug: string): string { + return `${siteUrl}/vt/${slug}` +} + + + + +export async function getVtuberBySlug(slug: string): Promise { + const query = qs.stringify( + { + filters: { + slug: { + $eq: slug + } + } + } + ) + + const res = await fetch(`${strapiUrl}/api/vtubers?${query}`); + if (!res.ok) { + console.error(`error inside getVtuberBySlug-- ${res.statusText}`); + return null; + } + const vtuber = await res.json(); + return vtuber.data[0]; +} + +export async function getVtuberById(id: number): Promise { + const res = await fetch(`${strapiUrl}/api/vtubers?filters[id][$eq]=${id}`); + if (!res.ok) { + console.error(`error inside getVtuberById-- ${res.statusText}`); + return null; + } + const vtuber = await res.json(); + return vtuber +} + +export async function getVtubers(): Promise { + const res = await fetch(`${strapiUrl}/api/vtubers`); + if (!res.ok) { + console.error(`error inside getVtubers-- ${res.statusText}`); + return null; + } + const vtubers = await res.json(); + return vtubers; + +} + +export async function getAllVtubers(): Promise { + const pageSize = 100; + + const allVtubers: IVtuber[] = []; + let currentPage = 1; + + while (true) { + const query = qs.stringify({ + // populate: { + // vtuber: { + // fields: ['slug', 'displayName', 'image', 'imageBlur'], + // }, + // muxAsset: { + // fields: ['playbackId', 'assetId'], + // }, + // thumbnail: { + // fields: ['cdnUrl', 'url'], + // }, + // tagVodRelations: { + // fields: ['tag'], + // populate: ['tag'], + // }, + // videoSrcB2: { + // fields: ['url', 'key', 'uploadId', 'cdnUrl'], + // }, + // }, + // sort: { + // date: sortDesc ? 'desc' : 'asc', + // }, + pagination: { + pageSize, + page: currentPage, + }, + }); + + try { + console.log(`getting /api/vtubers page=${currentPage}`); + const response = await fetch(`${strapiUrl}/api/vtubers?${query}`, fetchVtubersOptions); + + if (!response.ok) { + // Handle non-successful response (e.g., HTTP error) + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const responseData = await response.json(); + + if (!responseData.data || responseData.data.length === 0) { + // No more data to fetch + break; + } + + allVtubers.push(...responseData.data); + currentPage++; + } catch (error) { + // Handle fetch error + if (error instanceof Error) { + console.error('Error fetching data:', error.message); + } + return null; + } + } + + return allVtubers; +} \ No newline at end of file diff --git a/packages/next/app/page.tsx b/packages/next/app/page.tsx new file mode 100644 index 0000000..cd59161 --- /dev/null +++ b/packages/next/app/page.tsx @@ -0,0 +1,72 @@ + +import FundingGoal from "@/components/funding-goal"; +import VodCard from "@/components/vod-card"; +import { getVodTitle } from "@/components/vod-page"; +import { getVods } from '@/lib/vods'; +import { IVod } from "@/lib/vods"; +import { getVtubers, IVtuber } from "./lib/vtubers"; +import VTuberCard from "./components/vtuber-card"; +import Link from 'next/link'; +import { notFound } from "next/navigation"; + +export default async function Page() { + const vods = await getVods(1, 9, true); + const vtubers = await getVtubers(); + if (!vtubers) notFound(); + + // return ( + //
+  //     
+  //       {JSON.stringify(vods.data, null, 2)}
+  //     
+  //   
+ // ) + return ( + <> +
+
+
+

+ The Galaxy's Best VTuber Hentai Site +

+

For adults only (NSFW)

+
+
+ +
+ +
+ +
+ +

Latest VODs

+
+ + {vods.data.map((vod: IVod) => ( + + ))} +
+ + See all Latest Vods +
+
+ +

VTubers

+ {/* */} +
+
+ + ); +} diff --git a/packages/next/app/patrons/page.tsx b/packages/next/app/patrons/page.tsx new file mode 100644 index 0000000..9b10c62 --- /dev/null +++ b/packages/next/app/patrons/page.tsx @@ -0,0 +1,42 @@ + +import PatronsList from '../components/patrons-list'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; +import Link from 'next/link' +import { getCampaign } from '../lib/patreon'; + +export default async function Page() { + + const patreonCampaign = await getCampaign() + + return ( + <> +
+
+
+

Patron List

+

+ Futureporn.net continues to improve thanks to + {patreonCampaign.patronCount} generous supporters. +

+ + + +

Want to get your name on this list, and get perks like FAST video streaming?

+ + Become a patron today! + + +

+ Patron names are private by default--{' '} + Opt-in to have your name displayed. +

+
+
+
+ + ); +} diff --git a/packages/next/app/profile/page.tsx b/packages/next/app/profile/page.tsx new file mode 100644 index 0000000..8c5dcdb --- /dev/null +++ b/packages/next/app/profile/page.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useAuth, LoginButton, LogoutButton } from "../components/auth" +import { patreonVideoAccessBenefitId } from "../lib/constants"; +import UserControls from "../components/user-controls"; +import Skeleton, { SkeletonTheme } from "react-loading-skeleton" +import { skeletonHeight, skeletonBorderRadius, skeletonBaseColor, skeletonHighlightColor } from '../lib/constants' + +export default function Page() { + const { authData } = useAuth() + const isLoggedIn = (!!authData?.accessToken) + const isEntitledToCDN = (!!authData?.user?.patreonBenefits.split(',').includes(patreonVideoAccessBenefitId)) + + if (!authData) { + return
+ + + +
+ } + + + return ( + <> +
+ +

{authData?.user?.username} Profile

+ + {/* if not logged in, show login button */} + { + (!authData?.user) && ( + + ) + } + + {/* if logged in and not patron, display welcome */} + { + (!!authData?.accessToken && !isEntitledToCDN) && + <> +

Welcome to Futureporn, {authData?.user?.username || 'chatmember'}! It seems that you are not a patron yet. Please log out and log in again if you believe this is an error. Thank you for your interest!

+ + + } + + {/* if logged in and patron, display profile*/} + { + (!!authData?.user?.patreonBenefits && isEntitledToCDN) && + + } + + + + +
+ + ) +} \ No newline at end of file diff --git a/packages/next/app/streams/[cuid]/not-found.tsx b/packages/next/app/streams/[cuid]/not-found.tsx new file mode 100644 index 0000000..b9d167b --- /dev/null +++ b/packages/next/app/streams/[cuid]/not-found.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function NotFound() { + return ( +
+

404 Not Found

+

Could not find that stream.

+ + Return to streams list +
+ ) +} \ No newline at end of file diff --git a/packages/next/app/streams/[cuid]/page.tsx b/packages/next/app/streams/[cuid]/page.tsx new file mode 100644 index 0000000..e4971e0 --- /dev/null +++ b/packages/next/app/streams/[cuid]/page.tsx @@ -0,0 +1,20 @@ + +import StreamPage from '@/components/stream-page'; +import { getStreamByCuid } from '@/lib/streams'; + + +interface IPageParams { + params: { + cuid: string; + } +} + + +export default async function Page ({ params: { cuid } }: IPageParams) { + const stream = await getStreamByCuid(cuid); + return ( + <> + + + ) +} \ No newline at end of file diff --git a/packages/next/app/streams/page.tsx b/packages/next/app/streams/page.tsx new file mode 100644 index 0000000..1709cc3 --- /dev/null +++ b/packages/next/app/streams/page.tsx @@ -0,0 +1,34 @@ +import Pager from "@/components/pager"; +import StreamsCalendar from "@/components/streams-calendar"; +import StreamsList from "@/components/streams-list"; +import { getAllStreams } from "@/lib/streams"; +import { getAllVtubers } from "@/lib/vtubers"; +import { MissingStaticPage } from "next/dist/shared/lib/utils"; +import { notFound } from "next/navigation"; +// import { useState } from "react"; + + +export default async function Page() { + const vtubers = await getAllVtubers(); + const pageSize = 100; + const page = 1; + if (!vtubers) notFound(); + const missingStreams = await getAllStreams(['missing']); + const issueStreams = await getAllStreams(['issue']); + const goodStreams = await getAllStreams(['good']); + + return ( +
+ {/*
+                
+                    {JSON.stringify(vtubers, null, 2)}
+                
+            
*/} + + + {/* */} + + +
+ ) +} \ No newline at end of file diff --git a/packages/next/app/tags/[slug]/page.tsx b/packages/next/app/tags/[slug]/page.tsx new file mode 100644 index 0000000..04a9d47 --- /dev/null +++ b/packages/next/app/tags/[slug]/page.tsx @@ -0,0 +1,35 @@ +import { getVodsForTag, IVod } from '@/lib/vods' +import VodCard from '@/components/vod-card' +import Link from 'next/link' +import { getVodTitle } from '@/components/vod-page' +import { notFound } from 'next/navigation' + +export default async function Page({ params }: { params: { slug: string }}) { + const vods = await getVodsForTag(params.slug) + if (!vods) return notFound() + return ( +
+
+

Tagged “{params.slug}”

+
+ +
+ {vods.data.map((vod: IVod) => ( + + ))} +
+ +
+

See all tags.

+
+
+ ) +} \ No newline at end of file diff --git a/packages/next/app/tags/page.tsx b/packages/next/app/tags/page.tsx new file mode 100644 index 0000000..77c05b6 --- /dev/null +++ b/packages/next/app/tags/page.tsx @@ -0,0 +1,15 @@ +import { getTags } from '../lib/tags' +import SortableTags from '../components/sortable-tags' + +export default async function Page() { + const tags = await getTags(); + + return ( +
+
+

Tags

+ +
+
+ ) +} \ No newline at end of file diff --git a/packages/next/app/upload/page.tsx b/packages/next/app/upload/page.tsx new file mode 100644 index 0000000..0006657 --- /dev/null +++ b/packages/next/app/upload/page.tsx @@ -0,0 +1,26 @@ + +import { getAllVtubers } from '@/lib/vtubers'; +import UploadForm from '@/components/upload-form'; + +import '@uppy/core/dist/style.min.css'; +import '@uppy/dashboard/dist/style.min.css'; +import { getStreamByCuid } from '@/lib/streams'; + + +export default async function Page() { + + const vtubers = await getAllVtubers(); + + + return ( + <> + + {!vtubers + ? + : + } + + + + ) +} \ No newline at end of file diff --git a/packages/next/app/upload/page.tsx.old b/packages/next/app/upload/page.tsx.old new file mode 100644 index 0000000..80213e3 --- /dev/null +++ b/packages/next/app/upload/page.tsx.old @@ -0,0 +1,240 @@ +'use client' + +import React, { useEffect } from 'react'; +import Uppy from '@uppy/core'; +import { Dashboard } from '@uppy/react'; +import RemoteSources from '@uppy/remote-sources'; +import AwsS3Multipart from '@uppy/aws-s3-multipart'; +import '@uppy/core/dist/style.min.css'; +import '@uppy/dashboard/dist/style.min.css'; +import Image from 'next/image'; +import Link from 'next/link'; + +const uppy = new Uppy() + + + +// uppy.use(AwsS3Multipart, { +// limit: 6, +// companionUrl: process.env.NEXT_PUBLIC_UPPY_COMPANION_URL, +// // companionHeaders: { +// // // @todo +// // // Authorization: `Bearer ${Alpine.store('auth').jwt}` +// // } +// }) + + +// Dashboard, +// { +// inline: true, +// target: '#uppy-dashboard', +// theme: 'auto', +// proudlyDisplayPoweredByUppy: false, +// disableInformer: false, +// // metaFields: [ +// // @todo maybe add meta fields once https://github.com/transloadit/uppy/issues/4427 is fixed +// // { +// // id: 'announceUrl', +// // name: 'Stream Announcement URL', +// // placeholder: 'this is a placeholder' +// // }, +// // { +// // id: 'note', +// // name: 'Note' +// // } +// // { +// // id: 'date', +// // name: 'Stream Date (ISO 8601)', +// // placeholder: '2022-12-30' +// // }, +// // ] +// } +// ) + +// import Uppy from '@uppy/core'; +// import Dashboard from '@uppy/dashboard'; +// import '/@root/node_modules/@uppy/core/dist/style.min.css'; +// import '/@root/node_modules/@uppy/dashboard/dist/style.min.css'; + + + +export default function Page() { + // const dashboard = new Dashboard({ + // inline: true, + // target: '#uppy-dashboard', + // theme: 'dark', + // proudlyDisplayPoweredByUppy: false, + // disableInformer: false, + // }) + + + // useEffect(() => { + // uppy.setOptions({ + // Dashboard: { + // theme: 'dark' + // } + // }) + // }) + + // useEffect(() => { + // uppy.setOptions({ + // restrictions: props.restrictions + // }) + // }, [props.restrictions]) + + // .use( + // Dashboard, + // { + // inline: true, + // target: '#uppy-dashboard', + // theme: 'auto', + // proudlyDisplayPoweredByUppy: false, + // disableInformer: false, + // // metaFields: [ + // // @todo maybe add meta fields once https://github.com/transloadit/uppy/issues/4427 is fixed + // // { + // // id: 'announceUrl', + // // name: 'Stream Announcement URL', + // // placeholder: 'this is a placeholder' + // // }, + // // { + // // id: 'note', + // // name: 'Note' + // // } + // // { + // // id: 'date', + // // name: 'Stream Date (ISO 8601)', + // // placeholder: '2022-12-30' + // // }, + // // ] + // } + // ) + // .use(RemoteSources, { + // companionUrl: process.env.NEXT_PUBLIC_UPPY_COMPANION_URL, + // sources: ['Box', 'OneDrive', 'Dropbox', 'GoogleDrive', 'Url'], + // }) + // .use(AwsS3Multipart, { + // limit: 6, + // companionUrl: process.env.NEXT_PUBLIC_UPPY_COMPANION_URL, + // // companionHeaders: { + // // Authorization: `Bearer ${Alpine.store('auth').jwt}` + // // } + // }) + + return ( + <> + + + ) +} + +// export default function upload () { +// return { +// date: '', +// note: '', +// init () { +// const that = this +// const uppy = new Uppy({ +// onBeforeUpload (files) { +// if (!that.date) { +// const msg = 'File is missing a Stream Date' +// uppy.info(msg, 'error') +// throw new Error(msg) +// } +// }, +// restrictions: { +// maxNumberOfFiles: 1, +// // requiredMetaFields: [ +// // 'announceUrl', +// // 'date' +// // ] +// }, +// }) +// .use( +// Dashboard, +// { +// inline: true, +// target: '#uppy-dashboard', +// theme: 'auto', +// proudlyDisplayPoweredByUppy: false, +// disableInformer: false, +// // metaFields: [ +// // @todo maybe add meta fields once https://github.com/transloadit/uppy/issues/4427 is fixed +// // { +// // id: 'announceUrl', +// // name: 'Stream Announcement URL', +// // placeholder: 'this is a placeholder' +// // }, +// // { +// // id: 'note', +// // name: 'Note' +// // } +// // { +// // id: 'date', +// // name: 'Stream Date (ISO 8601)', +// // placeholder: '2022-12-30' +// // }, +// // ] +// } +// ) +// .use(RemoteSources, { +// companionUrl: window.companionUrl, +// sources: ['Box', 'OneDrive', 'Dropbox', 'GoogleDrive', 'Url'], +// }) +// .use(AwsS3Multipart, { +// limit: 6, +// companionUrl: window.companionUrl, +// companionHeaders: { +// Authorization: `Bearer ${Alpine.store('auth').jwt}` +// } +// }) + + +// uppy.on('file-added', (file) => { +// if (!that.date) { +// uppy.info("Please add the Stream Date to metadata", 'info', 5000) +// } +// }); + + +// uppy.on('complete', (result) => { +// // for each uploaded vod, create a Vod in Strapi +// result.successful.forEach(async (upload) => { +// const res = await fetch(`${Alpine.store('env').backend}/api/vod/createFromUppy`, { +// method: 'POST', +// headers: { +// 'Authorization': `Bearer ${Alpine.store('auth').jwt}`, +// 'Accept': 'application/json', +// 'Content-Type': 'application/json' +// }, +// body: JSON.stringify({ +// data: { +// date: that.date, +// videoSrcB2: { +// key: upload.s3Multipart.key, +// uploadId: upload.s3Multipart.uploadId +// }, +// note: that.note, +// } +// }) +// }) + +// if (res.ok) { +// uppy.info("Thank you. The VOD is queued for approval by a moderator.", 'success', 60000) +// } else { +// uppy.error("There was a problem while uploading. Please try again later.", 'error', 10000) +// } +// }) + +// }) +// } +// } +// } \ No newline at end of file diff --git a/packages/next/app/uppy.tsx b/packages/next/app/uppy.tsx new file mode 100644 index 0000000..64279fc --- /dev/null +++ b/packages/next/app/uppy.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React, { useState, createContext, useContext, useEffect } from 'react'; +import Uppy from '@uppy/core'; +import AwsS3 from '@uppy/aws-s3'; +import RemoteSources from '@uppy/remote-sources'; +import { useAuth } from './components/auth'; + +const companionUrl = process.env.NEXT_PUBLIC_UPPY_COMPANION_URL! + +export const UppyContext = createContext(new Uppy()); + +export default function UppyProvider({ + children +}: { + children: React.ReactNode +}) { + const { authData } = useAuth(); + const [uppy] = useState(() => new Uppy( + { + autoProceed: true + } + ) + .use(RemoteSources, { + companionUrl, + sources: ['GoogleDrive'] + }) + .use(AwsS3, { + companionUrl, + shouldUseMultipart: true, + abortMultipartUpload: () => {}, // @see https://github.com/transloadit/uppy/issues/1197#issuecomment-491756118 + companionHeaders: { + 'authorization': `Bearer ${authData?.accessToken}` + } + }) + ); + + + + return ( + + {children} + + ) +} diff --git a/packages/next/app/vods/[safeDateOrCuid]/page.tsx b/packages/next/app/vods/[safeDateOrCuid]/page.tsx new file mode 100644 index 0000000..3074670 --- /dev/null +++ b/packages/next/app/vods/[safeDateOrCuid]/page.tsx @@ -0,0 +1,16 @@ + +import VodPage from '@/components/vod-page'; +import { IVodPageProps, getVodFromSafeDateOrCuid } from '@/lib/vods'; +import { notFound } from 'next/navigation'; + + +/** + * This route exists as backwards compatibility for Futureporn 0.0.0 which was on neocities + * @see https://www.w3.org/Provider/Style/URI + */ +export default async function Page({ params: { safeDateOrCuid, slug } }: IVodPageProps) { + const vod = await getVodFromSafeDateOrCuid(safeDateOrCuid); + if (!vod) notFound(); + return +} + diff --git a/packages/next/app/vods/page.tsx b/packages/next/app/vods/page.tsx new file mode 100644 index 0000000..175ff08 --- /dev/null +++ b/packages/next/app/vods/page.tsx @@ -0,0 +1,6 @@ + +import { redirect } from 'next/navigation'; + +export default async function Page() { + redirect('/latest-vods/1') +} \ No newline at end of file diff --git a/packages/next/app/vt/[slug]/history/page.tsx b/packages/next/app/vt/[slug]/history/page.tsx new file mode 100644 index 0000000..4c25482 --- /dev/null +++ b/packages/next/app/vt/[slug]/history/page.tsx @@ -0,0 +1,70 @@ + +import { getVtuberBySlug } from '@/lib/vtubers'; +import { getAllStreamsForVtuber } from '@/lib/streams'; +import NotFound from '../not-found'; +import { DataRecord } from 'cal-heatmap/src/options/Options'; +import { Cal } from '@/components/cal'; + +interface IPageProps { + params: { + slug: string; + }; +} + +function getArchiveStatusValue(archiveStatus: string): number { + if (archiveStatus === 'good') return 2; + if (archiveStatus === 'issue') return 1; + else return 0 // missing +} + +function sortDataRecordsByDate(records: DataRecord[]) { + return records.sort((a, b) => { + if (typeof a.date === 'string' && typeof b.date === 'string') { + return a.date.localeCompare(b.date); + } else { + // Handle comparison when date is not a string (e.g., when it's a number) + // For instance, you might want to convert numbers to strings or use a different comparison logic. + // Example assuming number to string conversion: + return String(a.date).localeCompare(String(b.date)); + } + }); +} + + +export default async function Page({ params: { slug } }: IPageProps) { + const vtuber = await getVtuberBySlug(slug); + if (!vtuber) return + const streams = await getAllStreamsForVtuber(vtuber.id); + const streamsByYear: { [year: string]: DataRecord[] } = {}; + streams.forEach((stream) => { + const date = new Date(stream.attributes.date); + const year = date.getFullYear(); + if (!streamsByYear[year]) { + streamsByYear[year] = []; + } + streamsByYear[year].push({ + date: new Date(stream.attributes.date).toISOString(), + value: stream.attributes.archiveStatus, + }); + }); + // Sort the data records within each year's array + for (const year in streamsByYear) { + streamsByYear[year] = sortDataRecordsByDate(streamsByYear[year]); + } + + + return ( +
+ {Object.keys(streamsByYear).map((year) => { + return ( +
+

{year}

+ {/*
{JSON.stringify(streamsByYear[year], null, 2)}
*/} + +
+ ) + })} + +
+ ) +} diff --git a/packages/next/app/vt/[slug]/not-found.tsx b/packages/next/app/vt/[slug]/not-found.tsx new file mode 100644 index 0000000..8027f63 --- /dev/null +++ b/packages/next/app/vt/[slug]/not-found.tsx @@ -0,0 +1,12 @@ +import Link from 'next/link' + +export default function NotFound() { + return ( +
+

404 Not Found

+

Could not find a matching vtubler.

+ + Return to vtuber list +
+ ) +} \ No newline at end of file diff --git a/packages/next/app/vt/[slug]/page.tsx b/packages/next/app/vt/[slug]/page.tsx new file mode 100644 index 0000000..910cb64 --- /dev/null +++ b/packages/next/app/vt/[slug]/page.tsx @@ -0,0 +1,246 @@ +import VodsList from '@/components/vods-list'; +import Link from 'next/link'; +import { getVtuberBySlug } from '@/lib/vtubers' +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faExternalLinkAlt, faBagShopping } from "@fortawesome/free-solid-svg-icons"; +import { faFacebook, faInstagram, faPatreon, faYoutube, faTwitch, faTiktok, faXTwitter, faReddit, faDiscord } from "@fortawesome/free-brands-svg-icons"; +import Image from 'next/image'; +import OnlyfansIcon from "@/components/icons/onlyfans"; +import PornhubIcon from '@/components/icons/pornhub'; +import ThroneIcon from '@/components/icons/throne'; +import LinktreeIcon from '@/components/icons/linktree'; +import FanslyIcon from '@/components/icons/fansly'; +import ChaturbateIcon from '@/components/icons/chaturbate'; +import CarrdIcon from '@/components/icons/carrd'; +import styles from '@/assets/styles/icon.module.css'; + +import { getVodsForVtuber } from '@/lib/vods'; +import { notFound } from 'next/navigation'; +import ArchiveProgress from '@/components/archive-progress'; +import StreamsCalendar from '@/components/streams-calendar'; +import { getAllStreamsForVtuber, getStreamsForVtuber } from '@/lib/streams'; +import LinkableHeading from '@/components/linkable-heading'; + + + +export default async function Page({ params }: { params: { slug: string } }) { + const vtuber = await getVtuberBySlug(params.slug); + if (!vtuber) notFound(); + + const vods = await getVodsForVtuber(vtuber.id, 1, 9); + if (!vods) notFound(); + + const missingStreams = await getAllStreamsForVtuber(vtuber.id, ['missing']); + const issueStreams = await getAllStreamsForVtuber(vtuber.id, ['issue']); + const goodStreams = await getAllStreamsForVtuber(vtuber.id, ['good']); + + + + // return ( + // <> + //

hi mom!

+ //
+  //       
+  //         {JSON.stringify(missingStreams, null, 2)}
+  //       
+  //     
+ // + // ) + + return ( + <> + {vtuber && ( + <> +
+ +
+
+

{vtuber.attributes.displayName}

+
+
+
+ {vtuber.attributes.displayName} +
+
+
+

{vtuber.attributes.description1}

+

{vtuber.attributes.description2}

+
+
+ +

+ Socials +

+ + +
+
+ {vtuber.attributes.patreon && ( +
+ + Patreon + +
+ )} + {vtuber.attributes.twitter && ( +
+ + Twitter + +
+ )} + {vtuber.attributes.youtube && ( +
+ + YouTube + +
+ )} + {vtuber.attributes.twitch && ( +
+ + Twitch + +
+ )} + {vtuber.attributes.tiktok && ( +
+ + TikTok + +
+ )} + {vtuber.attributes.fansly && ( +
+ + Fansly + +
+ )} + {vtuber.attributes.onlyfans && ( +
+ + + + OnlyFans + +
+ )} + {vtuber.attributes.pornhub && ( +
+ + Pornhub + +
+ )} + {vtuber.attributes.reddit && ( +
+ + Reddit + +
+ )} + {vtuber.attributes.discord && ( +
+ + Discord + +
+ )} + {vtuber.attributes.instagram && ( +
+ + Instagram + +
+ )} + {vtuber.attributes.facebook && ( +
+ + Facebook + +
+ )} + {vtuber.attributes.merch && ( +
+ + Merch + +
+ )} + {vtuber.attributes.chaturbate && ( +
+ + Chaturbate + +
+ )} + {vtuber.attributes.throne && ( +
+ + Throne + +
+ )} + {vtuber.attributes.linktree && ( +
+ + Linktree + +
+ )} + {vtuber.attributes.carrd && ( +
+ + Carrd + +
+ )} +
+
+ + + {/*

+ Toys +

+ + <> + + {(toys.pagination.total > toySampleCount) && See all of {vtuber.displayName}'s toys} + */} + +

+ Vods +

+ + + { + (vtuber.attributes.vods) ? ( + See all {vtuber.attributes.displayName} vods + ) : (

No VODs have been added, yet.

) + } + +

+ Streams +

+ +{/* +

+ Archive Progress +

+ */} + + +
+ + )} + + ); +} diff --git a/packages/next/app/vt/[slug]/stream/[safeDate]/page.tsx b/packages/next/app/vt/[slug]/stream/[safeDate]/page.tsx new file mode 100644 index 0000000..4d1f71f --- /dev/null +++ b/packages/next/app/vt/[slug]/stream/[safeDate]/page.tsx @@ -0,0 +1,31 @@ + +import { Stream } from '@/components/stream'; +import { IStream, getStreamForVtuber } from '@/lib/streams'; +import { getVtuberBySlug } from '@/lib/vtubers'; +import NotFound from '../../not-found'; + +interface IPageProps { + params: { + safeDate: string; + slug: string; + }; +} + +export default async function Page({ params: { safeDate, slug } }: IPageProps) { + const vtuber = await getVtuberBySlug(slug); + if (!vtuber) return + const stream = await getStreamForVtuber(vtuber.id, safeDate); + if (!stream) return + + return ( +
+
+

Stream Page!

+

slug={slug} safeDate={safeDate}

+ + +
+
+ ) +} + diff --git a/packages/next/app/vt/[slug]/streams/page.tsx b/packages/next/app/vt/[slug]/streams/page.tsx new file mode 100644 index 0000000..6e6bdff --- /dev/null +++ b/packages/next/app/vt/[slug]/streams/page.tsx @@ -0,0 +1,23 @@ + +import { getVtuberBySlug } from '@/lib/vtubers'; +import { getStreamsForVtuber } from '@/lib/streams'; +import Pager from '@/components/pager'; +import { notFound } from 'next/navigation'; + +interface IPageParams { + params: { + slug: string; + } +} + +export default async function Page({ params }: IPageParams) { + const vtuber = await getVtuberBySlug(params.slug); + if (!vtuber) return

vtuber {params.slug} not found

+ const streams = await getStreamsForVtuber(vtuber.id, 1, 24); + if (!streams) return

streams not found

; + return ( + <> + + + ) +} \ No newline at end of file diff --git a/packages/next/app/vt/[slug]/toys/[page]/page.tsx b/packages/next/app/vt/[slug]/toys/[page]/page.tsx new file mode 100644 index 0000000..f29c912 --- /dev/null +++ b/packages/next/app/vt/[slug]/toys/[page]/page.tsx @@ -0,0 +1,33 @@ + +// import VodsList, { VodsListHeading } from '@/components/vods-list' +// import { getVtuberBySlug } from '@/lib/vtubers' +// // import { IToys, getToysForVtuber } from '@/lib/toys' +// import { ToysList, ToysListHeading } from '@/components/toys' +// import Pager from '@/components/pager' + +// interface IPageParams { +// params: { +// name: string; +// page: number; +// } +// } + +export default async function Page() { + // const vtuber = await getVtuberBySlug(params.slug) + return

Toys pages coming soon

+ // const toys: IToys = await getToysForVtuber(vtuber.id, params.page, 24) + // return ( + //
+ //
+ // + // + // + //
+ //
+ // ) +} \ No newline at end of file diff --git a/packages/next/app/vt/[slug]/toys/page.tsx b/packages/next/app/vt/[slug]/toys/page.tsx new file mode 100644 index 0000000..e1b8843 --- /dev/null +++ b/packages/next/app/vt/[slug]/toys/page.tsx @@ -0,0 +1,33 @@ + +// import VodsList, { VodsListHeading } from '@/components/vods-list' +// import { getVtuberBySlug } from '@/lib/vtubers' +// // import { IToys, getToysForVtuber } from '@/lib/toys' +// import { ToysList } from '@/components/toys' +// import Pager from '@/components/pager' + +interface IPageParams { + params: { + name: string; + } +} + +export default async function Page({ params }: IPageParams) { + // const vtuber = await getVtuberBySlug(params.slug) + return

toys pages coming soon

+ // const toys: IToys = await getToysForVtuber(vtuber.id, 1, 24) + // return ( + //
+ //
+ // {/* */} + // {/* */} + // + // + //
+ //
+ // ) +} \ No newline at end of file diff --git a/packages/next/app/vt/[slug]/vod/[safeDateOrCuid]/page.tsx b/packages/next/app/vt/[slug]/vod/[safeDateOrCuid]/page.tsx new file mode 100644 index 0000000..c5e615c --- /dev/null +++ b/packages/next/app/vt/[slug]/vod/[safeDateOrCuid]/page.tsx @@ -0,0 +1,12 @@ + +import VodPage from '@/components/vod-page' +import { IVodPageProps, getVodFromSafeDateOrCuid } from '@/lib/vods' +import { notFound } from 'next/navigation'; + + +export default async function Page({ params: { safeDateOrCuid } }: IVodPageProps) { + const vod = await getVodFromSafeDateOrCuid(safeDateOrCuid); + if (!vod) return notFound(); + return +} + diff --git a/packages/next/app/vt/[slug]/vod/page.tsx b/packages/next/app/vt/[slug]/vod/page.tsx new file mode 100644 index 0000000..b62229f --- /dev/null +++ b/packages/next/app/vt/[slug]/vod/page.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +interface IPageParams { + params: { + slug: string; + } +} + +export default function Page({ params: { slug } }: IPageParams) { + redirect(`/vt/${slug}/vods`) + return See {`/vt/${slug}/vods`} +} + diff --git a/packages/next/app/vt/[slug]/vods/[page]/page.tsx b/packages/next/app/vt/[slug]/vods/[page]/page.tsx new file mode 100644 index 0000000..4bd6aea --- /dev/null +++ b/packages/next/app/vt/[slug]/vods/[page]/page.tsx @@ -0,0 +1,43 @@ +import VodsList, { VodsListHeading } from '@/components/vods-list'; +import { getVtuberBySlug, getUrl } from '@/lib/vtubers'; +import { IVodsResponse, getVodsForVtuber } from '@/lib/vods'; +import Pager from '@/components/pager'; +import { notFound } from 'next/navigation'; + + +interface IPageParams { + params: { + slug: string; + page: string; + }; +} + +export default async function Page({ params }: IPageParams) { + let vtuber, vods; + const pageNumber = parseInt(params.page); + + try { + vtuber = await getVtuberBySlug(params.slug); + if (!vtuber) notFound(); + vods = await getVodsForVtuber(vtuber.id, pageNumber, 24, true); + } catch (error) { + // Handle the error here (e.g., display an error message) + console.error("An error occurred:", error); + // You might also want to return an error page or message + return
Error: {JSON.stringify(error)}
; + } + + + if (!vods) return

error

+ return ( + <> + + + + + ); +} diff --git a/packages/next/app/vt/[slug]/vods/page.tsx b/packages/next/app/vt/[slug]/vods/page.tsx new file mode 100644 index 0000000..3c40c7a --- /dev/null +++ b/packages/next/app/vt/[slug]/vods/page.tsx @@ -0,0 +1,26 @@ + +import VodsList, { VodsListHeading } from '@/components/vods-list' +import { getVtuberBySlug, getUrl } from '@/lib/vtubers' +import { IVodsResponse, getVodsForVtuber, getPaginatedUrl } from '@/lib/vods' +import Pager from '@/components/pager' +import { notFound } from 'next/navigation' + +interface IPageParams { + params: { + slug: string; + } +} + +export default async function Page({ params }: IPageParams) { + const vtuber = await getVtuberBySlug(params.slug) + if (!vtuber) notFound(); + const vods = await getVodsForVtuber(vtuber.id, 1, 24) + if (!vods) notFound(); + return ( + <> + + + + + ) +} \ No newline at end of file diff --git a/packages/next/app/vt/page.tsx b/packages/next/app/vt/page.tsx new file mode 100644 index 0000000..9e410eb --- /dev/null +++ b/packages/next/app/vt/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from 'next/navigation' +import VTuberCard from '../components/vtuber-card' +import { getVtubers, IVtuber } from '../lib/vtubers' + + +export default async function Page() { + const vtubers = await getVtubers() + if (!vtubers) notFound() + // return ( + //
+    //         
+    //             {JSON.stringify(vtubers, null, 2)}
+    //         
+    //     
+ // ) + return ( + <> +
+
+

VTubers

+ +
+
+ + ) +} \ No newline at end of file diff --git a/packages/next/assets/styles/calendar-heatmap.module.scss b/packages/next/assets/styles/calendar-heatmap.module.scss new file mode 100644 index 0000000..2e8f8bf --- /dev/null +++ b/packages/next/assets/styles/calendar-heatmap.module.scss @@ -0,0 +1,89 @@ +$cell-height : 10px; +$cell-width : 10px; +$cell-margin:2px; +$cell-weekdays-width: 30px; + +html { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} + +html, body { + height: 100%; + width: 100%; +} + +#container { + height: 514px; + width: 930px; + margin: 50px auto; +} + +.timeline { + margin: 20px; + margin-bottom: 60px; + + .timeline-months { + display: flex; + padding-left: $cell-weekdays-width; + + &-month { + width: $cell-width; + margin: $cell-margin; + border: 1px solid transparent; + font-size: 10px; + } + + .Jan~.Jan, + .Feb~.Feb, + .Mar~.Mar, + .Apr~.Apr, + .May~.May, + .Jun~.Jun, + .Jul~.Jul, + .Aug~.Aug, + .Sep~.Sep, + .Oct~.Oct, + .Nov~.Nov, + .Dec~.Dec { + visibility: hidden; + } + } + + &-body { + display: flex; + + .timeline-weekdays { + display: inline-flex; + flex-direction: column; + width: $cell-weekdays-width; + + &-weekday { + font-size: 10px; + height: $cell-height; + border: 1px solid transparent; + margin: $cell-margin; + vertical-align: middle; + } + } + + .timeline-cells { + display: inline-flex; + flex-direction: column; + flex-wrap: wrap; + height: #{(10 + 4) * 8}px; + + &-cell { + height: $cell-height; + width: $cell-width; + border: 1px solid rgba(0, 0, 0, 0.1); + margin: $cell-margin; + border-radius: 2px; + background-color: rgba(0, 0, 0, 0.05); + + &:hover { + border: 1px solid rgba(0, 0, 0, 0.3); + } + } + } + } +} \ No newline at end of file diff --git a/packages/next/assets/styles/cid.module.css b/packages/next/assets/styles/cid.module.css new file mode 100644 index 0000000..e195bb4 --- /dev/null +++ b/packages/next/assets/styles/cid.module.css @@ -0,0 +1,19 @@ +.container { + display: flex; + align-items: center; +} + +.cid { + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + flex: 1; +} + +.label { + width: 6em; +} + +.green { + color: rgb(52, 168, 115); +} \ No newline at end of file diff --git a/packages/next/assets/styles/fp.module.css b/packages/next/assets/styles/fp.module.css new file mode 100644 index 0000000..946e963 --- /dev/null +++ b/packages/next/assets/styles/fp.module.css @@ -0,0 +1,20 @@ + +.noselect { + user-select: none; +} + +.tagButton { + height: 2em; + margin-bottom: 0.5rem; + border-radius: 4px; +} + +.isTiny { + height: 1.5em; +} + +.grade { + font-family: Arial, Helvetica, sans-serif; + font-size: 8rem; + font-weight: bolder; +} \ No newline at end of file diff --git a/packages/next/assets/styles/global.sass b/packages/next/assets/styles/global.sass new file mode 100644 index 0000000..7057e6d --- /dev/null +++ b/packages/next/assets/styles/global.sass @@ -0,0 +1,50 @@ +@charset "utf-8" + +// Import a Google Font +@import url('https://fonts.googleapis.com/css?family=Nunito:400,700') + +body + background-color: rgb(23, 24, 28) + + +// Set your brand colors +$purple: #8A4D76 +$pink: #FA7C91 +$brown: #757763 +$beige-light: #D0D1CD +$beige-lighter: #EFF0EB + +// Update Bulma's global variables +$family-sans-serif: "Nunito", sans-serif +$grey-dark: $brown +$grey-light: $beige-light +$primary: $purple +$link: $pink +$widescreen-enabled: false +$fullhd-enabled: false + +// Update some of Bulma's component variables +$body-background-color: $beige-lighter +$control-border-width: 2px +$input-border-color: transparent +$input-shadow: none + +// Import only what you need from Bulma +// @import "../node_modules/bulma/sass/utilities/_all.sass" +// @import "../node_modules/bulma/sass/base/_all.sass" +// @import "../node_modules/bulma/sass/elements/button.sass" +// @import "../node_modules/bulma/sass/elements/container.sass" +// @import "../node_modules/bulma/sass/elements/title.sass" +// @import "../node_modules/bulma/sass/form/_all.sass" +// @import "../node_modules/bulma/sass/components/navbar.sass" +// @import "../node_modules/bulma/sass/layout/hero.sass" +// @import "../node_modules/bulma/sass/layout/section.sass" + +@import "../../node_modules/bulma/bulma.sass" + +@import "../../node_modules/bulma-prefers-dark/bulma-prefers-dark.sass" + +a.navbar-item:active, +a.navbar-item:focus, +a.navbar-item:focus-within + background-color: hsl(0, 0%, 20%) \ No newline at end of file diff --git a/packages/next/assets/styles/icon.module.css b/packages/next/assets/styles/icon.module.css new file mode 100644 index 0000000..5d3efba --- /dev/null +++ b/packages/next/assets/styles/icon.module.css @@ -0,0 +1,20 @@ + +svg.icon { + width: 1em; + height: 1em; + vertical-align: -0.125em; + fill: rgb(208, 209, 205); +} + +svg.icon path { + fill: rgb(208, 209, 205); +} + +svg.icon g path { + fill: rgb(208, 209, 205) !important; +} + +svg.bigIcon { + width: 10em; + height: 10em; +} \ No newline at end of file diff --git a/packages/next/assets/styles/player.module.css b/packages/next/assets/styles/player.module.css new file mode 100644 index 0000000..b3f531b --- /dev/null +++ b/packages/next/assets/styles/player.module.css @@ -0,0 +1,4 @@ + +.fpMediaPlayer { + --media-aspect-ratio: 1.7778; +} \ No newline at end of file diff --git a/packages/next/assets/svg/README.md b/packages/next/assets/svg/README.md new file mode 100644 index 0000000..bbf6f41 --- /dev/null +++ b/packages/next/assets/svg/README.md @@ -0,0 +1,5 @@ +# SVG in next/react + +see https://blog.logrocket.com/import-svgs-next-js-apps/ + +TL;DR: use https://react-svgr.com/playground/ to convert svg to jsx \ No newline at end of file diff --git a/packages/next/assets/svg/carrd.svg b/packages/next/assets/svg/carrd.svg new file mode 100644 index 0000000..9b1ead5 --- /dev/null +++ b/packages/next/assets/svg/carrd.svg @@ -0,0 +1 @@ +Carrd diff --git a/packages/next/assets/svg/chaturbate.svg b/packages/next/assets/svg/chaturbate.svg new file mode 100644 index 0000000..0ef00ed --- /dev/null +++ b/packages/next/assets/svg/chaturbate.svg @@ -0,0 +1,15 @@ +import * as React from "react" +const SvgComponent = (props) => ( + + + +) +export default SvgComponent diff --git a/packages/next/assets/svg/checkmark.svg b/packages/next/assets/svg/checkmark.svg new file mode 100644 index 0000000..23cce6f --- /dev/null +++ b/packages/next/assets/svg/checkmark.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/next/assets/svg/fansly.tsx b/packages/next/assets/svg/fansly.tsx new file mode 100644 index 0000000..fcf575d --- /dev/null +++ b/packages/next/assets/svg/fansly.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +const SvgComponent = (props) => ( + + + + +) +export default SvgComponent diff --git a/packages/next/assets/svg/ipfs.svg b/packages/next/assets/svg/ipfs.svg new file mode 100644 index 0000000..ea32d6e --- /dev/null +++ b/packages/next/assets/svg/ipfs.svg @@ -0,0 +1 @@ +IPFS \ No newline at end of file diff --git a/packages/next/assets/svg/linktree.svg b/packages/next/assets/svg/linktree.svg new file mode 100644 index 0000000..0f8d400 --- /dev/null +++ b/packages/next/assets/svg/linktree.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + diff --git a/packages/next/assets/svg/noun-adult-content-1731184.svg b/packages/next/assets/svg/noun-adult-content-1731184.svg new file mode 100644 index 0000000..8509fd0 --- /dev/null +++ b/packages/next/assets/svg/noun-adult-content-1731184.svg @@ -0,0 +1 @@ +Created by Anatolii Babiifrom the Noun Project \ No newline at end of file diff --git a/packages/next/assets/svg/noun-anime-3890912.svg b/packages/next/assets/svg/noun-anime-3890912.svg new file mode 100644 index 0000000..c5c376f --- /dev/null +++ b/packages/next/assets/svg/noun-anime-3890912.svg @@ -0,0 +1 @@ +Created by Kevinfrom the Noun Project \ No newline at end of file diff --git a/packages/next/assets/svg/noun-avatar-3546974.svg b/packages/next/assets/svg/noun-avatar-3546974.svg new file mode 100644 index 0000000..ec43ede --- /dev/null +++ b/packages/next/assets/svg/noun-avatar-3546974.svg @@ -0,0 +1 @@ +love charger copy 2Created by KEN111from the Noun Project \ No newline at end of file diff --git a/packages/next/assets/svg/noun-girl-842331.svg b/packages/next/assets/svg/noun-girl-842331.svg new file mode 100644 index 0000000..f609fb4 --- /dev/null +++ b/packages/next/assets/svg/noun-girl-842331.svg @@ -0,0 +1,4 @@ +Created by Zackary Cloefrom the Noun Project \ No newline at end of file diff --git a/packages/next/assets/svg/noun-network-1603820.svg b/packages/next/assets/svg/noun-network-1603820.svg new file mode 100644 index 0000000..8a3d5c0 --- /dev/null +++ b/packages/next/assets/svg/noun-network-1603820.svg @@ -0,0 +1 @@ +Created by Three Six Fivefrom the Noun Project \ No newline at end of file diff --git a/packages/next/assets/svg/onlyfans.svg b/packages/next/assets/svg/onlyfans.svg new file mode 100644 index 0000000..bba261f --- /dev/null +++ b/packages/next/assets/svg/onlyfans.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/next/assets/svg/pornhub.svg b/packages/next/assets/svg/pornhub.svg new file mode 100644 index 0000000..4b1b6bd --- /dev/null +++ b/packages/next/assets/svg/pornhub.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/next/assets/svg/throne.svg b/packages/next/assets/svg/throne.svg new file mode 100644 index 0000000..a396573 --- /dev/null +++ b/packages/next/assets/svg/throne.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/next/next.config.js b/packages/next/next.config.js new file mode 100644 index 0000000..1706724 --- /dev/null +++ b/packages/next/next.config.js @@ -0,0 +1,22 @@ +/** @type {import('next').NextConfig} */ +const path = require("path"); +const nextConfig = { + output: 'standalone', + reactStrictMode: false, + sassOptions: { + includePaths: [path.join(__dirname, "assets", "styles")], + }, + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'futureporn-b2.b-cdn.net', + port: '', + pathname: '/**', + }, + ], + } +}; + + +module.exports = nextConfig; diff --git a/packages/next/package.json b/packages/next/package.json new file mode 100644 index 0000000..f97e6e2 --- /dev/null +++ b/packages/next/package.json @@ -0,0 +1,79 @@ +{ + "name": "fp-next", + "version": "2.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "preinstall": "npx only-allow pnpm" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.1", + "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-brands-svg-icons": "^6.5.1", + "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/react-fontawesome": "^0.2.0", + "@fullcalendar/core": "^6.1.10", + "@fullcalendar/daygrid": "^6.1.10", + "@fullcalendar/interaction": "^6.1.10", + "@fullcalendar/multimonth": "^6.1.10", + "@fullcalendar/react": "^6.1.10", + "@hookform/error-message": "^2.0.1", + "@hookform/resolvers": "^3.3.4", + "@mux/blurhash": "^0.1.2", + "@mux/mux-player": "^2.3.1", + "@mux/mux-player-react": "^2.3.1", + "@paralleldrive/cuid2": "^2.2.2", + "@react-hookz/web": "^24.0.2", + "@types/lodash": "^4.14.202", + "@types/qs": "^6.9.11", + "@types/react": "^18.2.47", + "@types/react-dom": "^18.2.18", + "@uppy/aws-s3": "^3.6.0", + "@uppy/aws-s3-multipart": "^3.3.0", + "@uppy/core": "^3.8.0", + "@uppy/dashboard": "^3.7.1", + "@uppy/drag-drop": "^3.0.3", + "@uppy/file-input": "^3.0.4", + "@uppy/progress-bar": "^3.0.4", + "@uppy/react": "^3.2.1", + "@uppy/remote-sources": "^1.1.0", + "bulma": "^0.9.4", + "bulma-prefers-dark": "0.1.0-beta.1", + "cal-heatmap": "^4.2.4", + "date-fns": "^2.0.0", + "date-fns-tz": "^2.0.0", + "dayjs": "^1.11.10", + "feed": "^4.2.2", + "gray-matter": "^4.0.3", + "hls.js": "^1.5.1", + "lodash": "^4.17.21", + "lunarphase-js": "^2.0.1", + "multiformats": "^13.0.1", + "next": "^14.0.4", + "next-goatcounter": "^1.0.5", + "nextjs-toploader": "^1.6.4", + "plyr": "^3.7.8", + "plyr-react": "^5.3.0", + "prism-react-renderer": "^2.3.1", + "qs": "^6.11.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.49.3", + "react-loading-skeleton": "^3.3.1", + "react-toastify": "^9.1.3", + "sass": "^1.69.7", + "sharp": "^0.33.2", + "slugify": "^1.6.6", + "yup": "^1.3.3" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "eslint": "^8.56.0", + "eslint-config-next": "14.0.4", + "tsc": "^2.0.4", + "typescript": "5.3.3" + } +} diff --git a/packages/next/public/futureporn-icon.png b/packages/next/public/futureporn-icon.png new file mode 100644 index 0000000..037e639 Binary files /dev/null and b/packages/next/public/futureporn-icon.png differ diff --git a/packages/next/public/images/.keep b/packages/next/public/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/packages/next/public/images/cj_clippy.jpg b/packages/next/public/images/cj_clippy.jpg new file mode 100644 index 0000000..d1c8717 Binary files /dev/null and b/packages/next/public/images/cj_clippy.jpg differ diff --git a/packages/next/public/images/default-thumbnail.webp b/packages/next/public/images/default-thumbnail.webp new file mode 100644 index 0000000..d468e15 Binary files /dev/null and b/packages/next/public/images/default-thumbnail.webp differ diff --git a/packages/next/public/images/projekt-melody.jpg b/packages/next/public/images/projekt-melody.jpg new file mode 100644 index 0000000..41916fa Binary files /dev/null and b/packages/next/public/images/projekt-melody.jpg differ diff --git a/packages/next/public/images/projektmelody-thumbnail.webp b/packages/next/public/images/projektmelody-thumbnail.webp new file mode 100644 index 0000000..dfb8073 Binary files /dev/null and b/packages/next/public/images/projektmelody-thumbnail.webp differ diff --git a/packages/next/public/images/vercel.svg b/packages/next/public/images/vercel.svg new file mode 100644 index 0000000..08d0cd1 --- /dev/null +++ b/packages/next/public/images/vercel.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json new file mode 100644 index 0000000..2a88d71 --- /dev/null +++ b/packages/next/tsconfig.json @@ -0,0 +1,50 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/components/*": [ + "app/components/*" + ], + "@/lib/*": [ + "app/lib/*" + ], + "@/assets/*": [ + "assets/*" + ] + }, + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "dist/types/**/*.ts", + ".next/types/**/*.ts", + "assets/svg/tes.jsx" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/strapi/.dockerignore b/packages/strapi/.dockerignore new file mode 100644 index 0000000..e6fa5c0 --- /dev/null +++ b/packages/strapi/.dockerignore @@ -0,0 +1,8 @@ +.tmp/ +.cache/ +.git/ +build/ +node_modules/ +.env +data/ +backup/ \ No newline at end of file diff --git a/packages/strapi/.gitignore b/packages/strapi/.gitignore new file mode 100644 index 0000000..afa1f1d --- /dev/null +++ b/packages/strapi/.gitignore @@ -0,0 +1,118 @@ +.env* +tunnel.conf + +############################ +# OS X +############################ + +.DS_Store +.AppleDouble +.LSOverride +Icon +.Spotlight-V100 +.Trashes +._* + + +############################ +# Linux +############################ + +*~ + + +############################ +# Windows +############################ + +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.cab +*.msi +*.msm +*.msp + + +############################ +# Packages +############################ + +*.7z +*.csv +*.dat +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip +*.com +*.class +*.dll +*.exe +*.o +*.seed +*.so +*.swo +*.swp +*.swn +*.swm +*.out +*.pid + + +############################ +# Logs and databases +############################ + +.tmp +*.log +*.sql +*.sqlite +*.sqlite3 + + +############################ +# Misc. +############################ + +*# +ssl +.idea +nbproject +public/uploads/* +!public/uploads/.gitkeep + +############################ +# Node.js +############################ + +lib-cov +lcov.info +pids +logs +results +node_modules +.node_history + +############################ +# Tests +############################ + +testApp +coverage + +############################ +# Strapi +############################ + +.env +license.txt +exports +*.cache +dist +build +.strapi-updater.json diff --git a/packages/strapi/.nvmrc b/packages/strapi/.nvmrc new file mode 100644 index 0000000..a77793e --- /dev/null +++ b/packages/strapi/.nvmrc @@ -0,0 +1 @@ +lts/hydrogen diff --git a/packages/strapi/.strapi/client/app.js b/packages/strapi/.strapi/client/app.js new file mode 100644 index 0000000..e0f39d0 --- /dev/null +++ b/packages/strapi/.strapi/client/app.js @@ -0,0 +1,14 @@ +/** + * This file was automatically generated by Strapi. + * Any modifications made will be discarded. + */ +import i18N from "@strapi/plugin-i18n/strapi-admin"; +import usersPermissions from "@strapi/plugin-users-permissions/strapi-admin"; +import { renderAdmin } from "@strapi/strapi/admin"; + +renderAdmin(document.getElementById("strapi"), { + plugins: { + i18n: i18N, + "users-permissions": usersPermissions, + }, +}); diff --git a/packages/strapi/.strapi/client/index.html b/packages/strapi/.strapi/client/index.html new file mode 100644 index 0000000..08d9c27 --- /dev/null +++ b/packages/strapi/.strapi/client/index.html @@ -0,0 +1,62 @@ + + + + + + + + + Strapi Admin + + + +
+ + + diff --git a/packages/strapi/Dockerfile b/packages/strapi/Dockerfile new file mode 100644 index 0000000..107ffc9 --- /dev/null +++ b/packages/strapi/Dockerfile @@ -0,0 +1,19 @@ +FROM node:18.16-alpine +# Installing libvips-dev for sharp Compatibility +RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev +ARG NODE_ENV=development +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /opt/ +COPY package.json yarn.lock ./ +RUN yarn config set network-timeout 600000 -g && yarn install + +WORKDIR /opt/app +COPY . . +ENV PATH /opt/node_modules/.bin:$PATH +RUN chown -R node:node /opt/app +USER node +RUN ["yarn", "build"] +EXPOSE 1337 +# CMD ["yarn", "develop"] +CMD yarn develop \ No newline at end of file diff --git a/packages/strapi/README.md b/packages/strapi/README.md new file mode 100644 index 0000000..34338c4 --- /dev/null +++ b/packages/strapi/README.md @@ -0,0 +1,7 @@ +## dev notes + +### patreon campaign benefit ids + + * ironmouse "Thank you" (for testing): 4760169 + * cj_clippy "Full library access" (for production): 9380584 + * cj_clippy "Your URL displayed on Futureporn.net": 10663202 diff --git a/packages/strapi/backup/Dockerfile.1704607848934 b/packages/strapi/backup/Dockerfile.1704607848934 new file mode 100644 index 0000000..9b69e59 --- /dev/null +++ b/packages/strapi/backup/Dockerfile.1704607848934 @@ -0,0 +1,49 @@ +# FROM node:16-alpine as build +# # Installing libvips-dev for sharp Compatibility +# RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev vips-dev > /dev/null 2>&1 +# ARG NODE_ENV=production +# ENV NODE_ENV=${NODE_ENV} +# WORKDIR /opt/ +# COPY ./package.json ./yarn.lock ./ +# ENV PATH /opt/node_modules/.bin:$PATH +# RUN yarn config set network-timeout 600000 -g && yarn install --production +# WORKDIR /opt/app +# COPY ./ . +# RUN yarn build +# FROM node:16-alpine +# RUN apk add --no-cache vips-dev + +# FROM node:16-alpine +# RUN apk add --no-cache vips-dev +# ARG NODE_ENV=production +# ENV NODE_ENV=${NODE_ENV} +# WORKDIR /opt/app +# COPY --from=build /opt/node_modules ./node_modules +# ENV PATH /opt/node_modules/.bin:$PATH +# COPY --from=build /opt/app ./ +# EXPOSE 5555 +# CMD ["yarn", "start"] + + + + +# # following is from the strapi website +FROM node:18-alpine +# Installing libvips-dev for sharp Compatibility +RUN apk update && apk add --no-cache build-base gcc autoconf automake zlib-dev libpng-dev nasm bash vips-dev git +ARG NODE_ENV=development +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /opt/ +COPY package.json yarn.lock ./ +RUN yarn global add node-gyp +RUN yarn config set network-timeout 600000 -g && yarn install +ENV PATH /opt/node_modules/.bin:$PATH + +WORKDIR /opt/app +COPY . . +RUN chown -R node:node /opt/app +USER node +RUN ["yarn", "build"] +EXPOSE 1337 +CMD ["yarn", "dev"] diff --git a/packages/strapi/chisel.sh b/packages/strapi/chisel.sh new file mode 100644 index 0000000..b612516 --- /dev/null +++ b/packages/strapi/chisel.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +. .env +chisel client --auth="${CHISEL_AUTH}" "${CHISEL_SERVER}" R:8900:localhost:1337 + diff --git a/packages/strapi/config/admin.js b/packages/strapi/config/admin.js new file mode 100644 index 0000000..92f535b --- /dev/null +++ b/packages/strapi/config/admin.js @@ -0,0 +1,13 @@ +module.exports = ({ env }) => ({ + auth: { + secret: env('ADMIN_JWT_SECRET'), + }, + apiToken: { + salt: env('API_TOKEN_SALT'), + }, + transfer: { + token: { + salt: env('TRANSFER_TOKEN_SALT'), + }, + }, +}); diff --git a/packages/strapi/config/api.js b/packages/strapi/config/api.js new file mode 100644 index 0000000..62f8b65 --- /dev/null +++ b/packages/strapi/config/api.js @@ -0,0 +1,7 @@ +module.exports = { + rest: { + defaultLimit: 25, + maxLimit: 100, + withCount: true, + }, +}; diff --git a/packages/strapi/config/database.js b/packages/strapi/config/database.js new file mode 100644 index 0000000..63290e0 --- /dev/null +++ b/packages/strapi/config/database.js @@ -0,0 +1,49 @@ +const path = require('path'); + +module.exports = ({ env }) => { + const client = env('DATABASE_CLIENT', 'postgres'); + + const connections = { + postgres: { + connection: { + connectionString: env('DATABASE_URL'), + host: env('DATABASE_HOST', 'localhost'), + port: env.int('DATABASE_PORT', 5432), + database: env('DATABASE_NAME', 'strapi'), + user: env('DATABASE_USERNAME', 'strapi'), + password: env('DATABASE_PASSWORD', 'strapi'), + ssl: env.bool('DATABASE_SSL', false) && { + key: env('DATABASE_SSL_KEY', undefined), + cert: env('DATABASE_SSL_CERT', undefined), + ca: env('DATABASE_SSL_CA', undefined), + capath: env('DATABASE_SSL_CAPATH', undefined), + cipher: env('DATABASE_SSL_CIPHER', undefined), + rejectUnauthorized: env.bool( + 'DATABASE_SSL_REJECT_UNAUTHORIZED', + true + ), + }, + schema: env('DATABASE_SCHEMA', 'public'), + }, + pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) }, + }, + sqlite: { + connection: { + filename: path.join( + __dirname, + '..', + env('DATABASE_FILENAME', 'data.db') + ), + }, + useNullAsDefault: true, + }, + }; + + return { + connection: { + client, + ...connections[client], + acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000), + }, + }; +}; diff --git a/packages/strapi/config/middlewares.js b/packages/strapi/config/middlewares.js new file mode 100644 index 0000000..c8b8699 --- /dev/null +++ b/packages/strapi/config/middlewares.js @@ -0,0 +1,26 @@ +module.exports = [ + 'strapi::errors', + { + name: 'strapi::security', + config: { + contentSecurityPolicy: { + useDefaults: true, + directives: { + 'connect-src': ["'self'", 'https:'], + 'img-src': ["'self'", 'data:', 'blob:', 'dl.airtable.com', 'res.cloudinary.com'], + 'media-src': ["'self'", 'data:', 'blob:', 'dl.airtable.com', 'res.cloudinary.com'], + upgradeInsecureRequests: null, + }, + }, + }, + }, + + 'strapi::cors', + 'strapi::poweredBy', + 'strapi::logger', + 'strapi::query', + 'strapi::body', + 'strapi::session', + 'strapi::favicon', + 'strapi::public', +]; diff --git a/packages/strapi/config/plugins.js b/packages/strapi/config/plugins.js new file mode 100644 index 0000000..a47d2a5 --- /dev/null +++ b/packages/strapi/config/plugins.js @@ -0,0 +1,75 @@ +module.exports = ({ + env +}) => ({ + 'fuzzy-search': { + enabled: true, + config: { + contentTypes: [{ + uid: 'api::tag.tag', + modelName: 'tag', + transliterate: false, + queryConstraints: { + where: { + '$and': [ + { + publishedAt: { + '$notNull': true + } + }, + ] + } + }, + fuzzysortOptions: { + characterLimit: 32, + threshold: -600, + limit: 10, + keys: [{ + name: 'name', + weight: 100 + }] + } + }] + } + }, + upload: { + config: { + provider: 'cloudinary', + providerOptions: { + cloud_name: env('CLOUDINARY_NAME'), + api_key: env('CLOUDINARY_KEY'), + api_secret: env('CLOUDINARY_SECRET'), + }, + actionOptions: { + upload: {}, + uploadStream: {}, + delete: {}, + }, + } + }, + email: { + config: { + provider: 'sendgrid', + providerOptions: { + apiKey: env('SENDGRID_API_KEY'), + }, + settings: { + defaultFrom: 'welcome@futureporn.net', + defaultReplyTo: 'cj@futureporn.net', + testAddress: 'grimtech@fastmail.com', + }, + }, + }, + "users-permissions": { + config: { + register: { + allowedFields: [ + "isNamePublic", + "isLinkPublic", + "avatar", + "vanityLink", + "patreonBenefits" + ] + } + } + } +}); \ No newline at end of file diff --git a/packages/strapi/config/server.js b/packages/strapi/config/server.js new file mode 100644 index 0000000..26a385f --- /dev/null +++ b/packages/strapi/config/server.js @@ -0,0 +1,15 @@ +// greets some + +module.exports = ({ env }) => ({ + host: env('HOST', '0.0.0.0'), + port: env.int('PORT', 1337), + proxy: true, + app: { + keys: env.array('APP_KEYS'), + }, + webhooks: { + populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false) + }, + url: env('STRAPI_URL', 'https://portal.futureporn.net') +}); + diff --git a/packages/strapi/database/daily-backup.sh b/packages/strapi/database/daily-backup.sh new file mode 100644 index 0000000..1ee0aeb --- /dev/null +++ b/packages/strapi/database/daily-backup.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# daily-backup.sh +# useful for the dokku server +# dokku's backup feature is broken atm https://github.com/dokku/dokku-postgres/issues/274 +# backups are exported from dokku:postgres plugin before being sent to b2 + + +filename="$(date +'%Y-%m-%d_%H-%M-%S').psql" + +dokku postgres:export futureporn-db > "${filename}" +b2-linux upload-file futureporn-db-backup "./${filename}" "${filename}" + diff --git a/packages/strapi/database/devDb.sh b/packages/strapi/database/devDb.sh new file mode 100755 index 0000000..a293e46 --- /dev/null +++ b/packages/strapi/database/devDb.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +. .env + +# Check if the containers already exist +pgadmin_exists=$(docker ps -a --filter "name=pgadmin" --format '{{.Names}}') +strapi_postgres_exists=$(docker ps -a --filter "name=strapi-postgres" --format '{{.Names}}') + +# Run strapi-postgres container if it doesn't exist or is not running +if [ -z "$strapi_postgres_exists" ]; then + docker run -d --name strapi-postgres -p 5432:5432 -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD postgres:14.7 + echo "strapi-postgres container created and started." +else + container_status=$(docker inspect -f '{{.State.Status}}' strapi-postgres) + + if [ "$container_status" != "running" ]; then + docker start strapi-postgres + echo "strapi-postgres container started." + else + echo "strapi-postgres container already exists and is running. Skipping creation." + fi +fi diff --git a/packages/strapi/database/migrations/.gitkeep b/packages/strapi/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/strapi/database/migrations/2023-08-01-relate-vods-to-vtubers-part2.js b/packages/strapi/database/migrations/2023-08-01-relate-vods-to-vtubers-part2.js new file mode 100644 index 0000000..502ec0c --- /dev/null +++ b/packages/strapi/database/migrations/2023-08-01-relate-vods-to-vtubers-part2.js @@ -0,0 +1,25 @@ +module.exports = { + async up(knex) { + // ... (Create vods_vtuber_links table if not already created) + + // Get vtuber ID for ProjektMelody (assuming it's 1) + const vtuberId = 1; + + // Get all VODs from the database + const vods = await knex.select('*').from('vods'); + + // For each VOD, associate it with the vtuber (vtuber with ID 1) if not already associated + for (const [index, vod] of vods.entries()) { + const existingAssociation = await knex('vods_vtuber_links') + .where({ vtuber_id: vtuberId, vod_id: vod.id }) + .first(); + if (!existingAssociation) { + await knex('vods_vtuber_links').insert({ + vtuber_id: vtuberId, + vod_id: vod.id, + vod_order: index + 1, // Auto-increment the vod_order number + }); + } + } + }, +}; diff --git a/packages/strapi/database/migrations/2023-08-17-reformat-cdnUrl.js b/packages/strapi/database/migrations/2023-08-17-reformat-cdnUrl.js new file mode 100644 index 0000000..c090e12 --- /dev/null +++ b/packages/strapi/database/migrations/2023-08-17-reformat-cdnUrl.js @@ -0,0 +1,18 @@ +module.exports = { + async up(knex) { + + // Get all B2 Files from the database + const files = await knex.select('*').from('b2_files'); + + // For each B2 File, update cdnUrl + // we do this to change + // erroneous https://futureporn-b2.b-cdn.net/futureporn/:key + // to https://futureporn-b2.b-cdn.net/:key + for (const [index, file] of files.entries()) { + const key = file.key; + const cdnUrl = `https://futureporn-b2.b-cdn.net/${key}`; + await knex('b2_files').update({ cdn_url: cdnUrl }).where({ id: file.id }); + } + }, + }; + \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023-08-20-strip-query-string-from-cid.js b/packages/strapi/database/migrations/2023-08-20-strip-query-string-from-cid.js new file mode 100644 index 0000000..0a254f2 --- /dev/null +++ b/packages/strapi/database/migrations/2023-08-20-strip-query-string-from-cid.js @@ -0,0 +1,23 @@ +const stripQueryString = function (text) { + if (!text) return ''; + return text.split(/[?#]/)[0]; +} + +module.exports = { + async up(knex) { + + // Get all vods + const vods = await knex.select('*').from('vods'); + + // For each vod, update videoSrcHash and video240Hash + // we remove any existing ?filename(...) qs from the cid + for (const [index, vod] of vods.entries()) { + const strippedVideoSrcHash = stripQueryString(vod.video_src_hash) + const strippedVideo240Hash = stripQueryString(vod.video_240_hash) + await knex('vods').update({ video_src_hash: strippedVideoSrcHash }).where({ id: vod.id }); + await knex('vods').update({ video_240_hash: strippedVideo240Hash }).where({ id: vod.id }); + } + + }, + }; + \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023-08-30-remove-cloudinary.js b/packages/strapi/database/migrations/2023-08-30-remove-cloudinary.js new file mode 100644 index 0000000..6f1f5a7 --- /dev/null +++ b/packages/strapi/database/migrations/2023-08-30-remove-cloudinary.js @@ -0,0 +1,13 @@ +module.exports = { + async up(knex) { + + const toys = await knex.select('*').from('toys'); + for (const [index, toy] of toys.entries()) { + if (toy.image_2) { + const existingImageFilename = new URL(toy.image_2).pathname.split('/').at(-1) + await knex('toys').update({ image_2: `https://futureporn-b2.b-cdn.net/${existingImageFilename}` }).where({ id: toy.id }); + } + } + }, + }; + \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023-08-30-toy-image-field-simplify.js b/packages/strapi/database/migrations/2023-08-30-toy-image-field-simplify.js new file mode 100644 index 0000000..66ab1de --- /dev/null +++ b/packages/strapi/database/migrations/2023-08-30-toy-image-field-simplify.js @@ -0,0 +1,33 @@ +module.exports = { + async up(knex) { + // Add the `image2` field (column) as a short text field + await knex.schema.table('toys', (table) => { + table.string('image_2', 512); + }); + + // Get all toys + const toys = await knex.select('*').from('toys'); + + // Update the image2 field with the previous image URLs + for (const toy of toys) { + // lookup the file morph which maps toy to (image) file + const imageFileId = (await knex.select('file_id').from('files_related_morphs').where({ related_id: toy.id }))[0].file_id + + // get the image data from the file + const imageUrl = (await knex.select('url').from('files').where({ id: imageFileId }))[0].url + + if (!imageUrl) continue; + + // Copy the values from image to image2 + await knex('toys').update({ image_2: imageUrl }).where({ id: toy.id }); + } + + const hasImageColumn = await knex.schema.hasColumn('toys', 'image'); + if (hasImageColumn) { + // Drop the `image` column + table.dropColumn('image'); + } + + + }, +}; diff --git a/packages/strapi/database/migrations/2023-09-08-change-date-to-string.js b/packages/strapi/database/migrations/2023-09-08-change-date-to-string.js new file mode 100644 index 0000000..3b03cc0 --- /dev/null +++ b/packages/strapi/database/migrations/2023-09-08-change-date-to-string.js @@ -0,0 +1,23 @@ +module.exports = { + async up(knex) { + // Check if the 'date_2' column exists in the 'vods' table + const hasDate2Column = await knex.schema.hasColumn('vods', 'date_2'); + + if (!hasDate2Column) { + // Add the new 'date_2' column as a string if it doesn't exist + await knex.schema.table('vods', (table) => { + table.string('date_2'); + }); + + // Fetch all existing rows from the 'vods' table + const existingVods = await knex.select('id', 'date').from('vods'); + + // Loop through each row and update 'date_2' with the date value + for (const vod of existingVods) { + await knex('vods') + .where({ id: vod.id }) + .update({ date_2: vod.date.toISOString() }); + } + } + }, +}; diff --git a/packages/strapi/database/migrations/2023-09-08-drop-toys-image.js b/packages/strapi/database/migrations/2023-09-08-drop-toys-image.js new file mode 100644 index 0000000..2119714 --- /dev/null +++ b/packages/strapi/database/migrations/2023-09-08-drop-toys-image.js @@ -0,0 +1,11 @@ +module.exports = { + async up(knex) { + const hasColumn = await knex.schema.hasColumn('toys', 'image'); + + if (hasColumn) { + await knex.schema.table('toys', (table) => { + table.dropColumn('image'); + }); + } + } +}; diff --git a/packages/strapi/database/migrations/2023-09-08-drop-vod-videosrc.js b/packages/strapi/database/migrations/2023-09-08-drop-vod-videosrc.js new file mode 100644 index 0000000..8d1f878 --- /dev/null +++ b/packages/strapi/database/migrations/2023-09-08-drop-vod-videosrc.js @@ -0,0 +1,11 @@ +module.exports = { + async up(knex) { + const hasColumn = await knex.schema.hasColumn('vods', 'video_src'); + + if (hasColumn) { + await knex.schema.table('vods', (table) => { + table.dropColumn('video_src'); + }); + } + } +}; diff --git a/packages/strapi/database/migrations/2023-12-24-add-cuid-to-vods.js b/packages/strapi/database/migrations/2023-12-24-add-cuid-to-vods.js new file mode 100644 index 0000000..5590b99 --- /dev/null +++ b/packages/strapi/database/migrations/2023-12-24-add-cuid-to-vods.js @@ -0,0 +1,31 @@ + +const generateCuid = require('../../misc/generateCuid'); + +module.exports = { + async up(knex) { + + console.log(`MIGRATION-- 2023-12-24-add-cuid-to-vods.js`); + + // Check if the 'cuid' column already exists in the 'vods' table + const hasCuidColumn = await knex.schema.hasColumn('vods', 'cuid'); + + if (!hasCuidColumn) { + // Add the 'cuid' column to the 'vods' table + await knex.schema.table('vods', (table) => { + table.string('cuid'); + }); + } + + // Get all vods from the database + const vods = await knex.select('*').from('vods'); + + // For each vod, populate cuid if it's null or undefined + for (const [index, vod] of vods.entries()) { + if (!vod.cuid) { + await knex('vods').update({ cuid: generateCuid() }).where({ id: vod.id }); + } + } + + }, +}; + diff --git a/packages/strapi/database/migrations/2023-12-26-add-cuid-to-streams.js b/packages/strapi/database/migrations/2023-12-26-add-cuid-to-streams.js new file mode 100644 index 0000000..942854b --- /dev/null +++ b/packages/strapi/database/migrations/2023-12-26-add-cuid-to-streams.js @@ -0,0 +1,33 @@ + +const { init } = require('@paralleldrive/cuid2'); + +module.exports = { + async up(knex) { + + console.log(`MIGRATION-- 2023-12-26-add-cuid-to-streams.js`); + + // Check if the 'cuid' column already exists in the 'streams' table + const hasCuidColumn = await knex.schema.hasColumn('streams', 'cuid'); + + if (!hasCuidColumn) { + // Add the 'cuid' column to the 'streams' table + await knex.schema.table('streams', (table) => { + table.string('cuid'); + }); + } + + // Get all streams from the database + const streams = await knex.select('*').from('streams'); + + // For each stream, populate cuid if it's null or undefined + for (const [index, stream] of streams.entries()) { + if (!stream.cuid) { + const length = 10; + const genCuid = init({ length }); + await knex('streams').update({ cuid: genCuid() }).where({ id: stream.id }); + } + } + + }, +}; + diff --git a/packages/strapi/database/migrations/2023-12-27-relate-vods-to-streams.js b/packages/strapi/database/migrations/2023-12-27-relate-vods-to-streams.js new file mode 100644 index 0000000..703275d --- /dev/null +++ b/packages/strapi/database/migrations/2023-12-27-relate-vods-to-streams.js @@ -0,0 +1,35 @@ + +const { sub, add } = require('date-fns'); + + +module.exports = { + async up(knex) { + console.log(`MIGRATION-- 2023-12-27-relate-vods-to-streams.js`); + + // Get all VODs from the database + const vods = await knex.select('*').from('vods'); + + // For each VOD, associate it with the stream with the nearest date (if not already associated) + for (const [index, vod] of vods.entries()) { + const existingAssociation = await knex('vods_stream_links') + .where({ vod_id: vod.id }) + .first(); + + if (!existingAssociation) { + // get nearest stream within +/- 3 hours + const date2 = new Date(vod.date_2); + const startDate = sub(date2, { hours: 3 }) + const endDate = add(date2, { hours: 3 }); + console.log(`vod.id=${vod.id}, vod.date_2=${vod.date_2}, date2=${date2}, startDate=${startDate}, endDate=${endDate}`) + const stream = await knex('streams') + .whereBetween('date', [startDate, endDate]) + + await knex('vods_stream_links').insert({ + stream_id: stream.id, + vod_id: vod.id, + vod_order: 1, + }); + } + } + }, +}; diff --git a/packages/strapi/database/migrations/2023.05.09-video-src-sanity.js.noexec b/packages/strapi/database/migrations/2023.05.09-video-src-sanity.js.noexec new file mode 100644 index 0000000..cda7f9d --- /dev/null +++ b/packages/strapi/database/migrations/2023.05.09-video-src-sanity.js.noexec @@ -0,0 +1,26 @@ + +const fetch = require('node-fetch') + +let problemUrls = [] + +async function checkUrl(url) { + const res = await fetch(url); + if (!res.ok || !res?.headers?.get('x-bz-file-name') || !res?.headers?.get('x-bz-file-id')) problemUrls.push(url) +} + + + +module.exports = { + async up(knex) { + + // Get all VODs from the database + const vods = await knex.select('*').from('vods'); + + // sanity check every B2 URL + for (const vod of vods) { + await checkUrl(vod.video_src) + } + + process.exit(5923423) + }, +}; \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023.05.11T12.32.00.convert-to-video-src-b2.js.noexec b/packages/strapi/database/migrations/2023.05.11T12.32.00.convert-to-video-src-b2.js.noexec new file mode 100644 index 0000000..41d8608 --- /dev/null +++ b/packages/strapi/database/migrations/2023.05.11T12.32.00.convert-to-video-src-b2.js.noexec @@ -0,0 +1,98 @@ + +const fetch = require('node-fetch') + +// greets chatgpt +async function getFileDetailsFromUrl(url) { + const controller = new AbortController(); + const signal = controller.signal; + + const options = { + signal, + }; + + let retries = 10; + + while (retries) { + console.log(`fetching ${url}`); + const timeoutId = setTimeout(() => { + console.log('fetch timed out, aborting...'); + controller.abort(); + }, 5000); + + try { + const res = await fetch(url, options); + + clearTimeout(timeoutId); + + console.log('finished fetch'); + if (!res.ok) throw new Error(`problem while getting file from url with url ${url}`); + if (!res?.headers?.get('x-bz-file-name')) throw new Error(`${url} did not have a x-bz-file-name in the response headers`); + if (!res?.headers?.get('x-bz-file-id')) throw new Error(`${url} did not have a x-bz-file-id in the response headers`); + + return { + key: res.headers.get('x-bz-file-name'), + url: url, + uploadId: res.headers.get('x-bz-file-id'), + }; + } catch (err) { + clearTimeout(timeoutId); + retries--; + + if (retries === 0) { + console.error(`Could not fetch file details from URL: ${url}.`); + throw err; + } + + console.warn(`Retrying fetch (${retries} attempts left)`); + } + } +} + + + + +module.exports = { + async up(knex) { + // You have full access to the Knex.js API with an already initialized connection to the database + + // Get all VODs from the database + const vods = await knex.select('*').from('vods'); + + + // Process each VOD + for (const vod of vods) { + + // courtesy timer + await new Promise((resolve) => setTimeout(resolve, 1000)) + + console.log(vod) + // Get the file details from the VOD's video source URL + if (vod?.video_src) { + try { + const fileDetails = await getFileDetailsFromUrl(vod.video_src); + + // Insert the B2 file into the database + const [file] = await knex('b2_files').insert({ + url: fileDetails.url, + key: fileDetails.key, + upload_id: fileDetails.uploadId, + }).returning('id'); + + console.log(file) + console.log(`attempting to insert vod_id:${vod.id}, b_2_file_id:${file.id} for videoSrcB2`) + + // Link the B2 file to the VOD + await knex('vods_video_src_b_2_links').insert({ + vod_id: vod.id, + b_2_file_id: file.id, + }); + } catch (e) { + console.error(e) + console.log(`there was an error so we are skipping vod ${vod.id}`) + } + } else { + console.log(`${vod.id} has no video_src. skipping.`) + } + } + }, +}; \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023.05.14T00.42.00.000Z.migrate-tags-to-tag-vod-relations.js b/packages/strapi/database/migrations/2023.05.14T00.42.00.000Z.migrate-tags-to-tag-vod-relations.js new file mode 100644 index 0000000..7c3e24a --- /dev/null +++ b/packages/strapi/database/migrations/2023.05.14T00.42.00.000Z.migrate-tags-to-tag-vod-relations.js @@ -0,0 +1,43 @@ + +// up until now, tags have been attached directly to each vod object. +// now, tags are not attached to vods. +// instead, tag-vod-relations are used to associate a tag with a vod + +// what we need to do in this migration is +// * create a new tag-vod-relation for each tag in each vod +// * delete tags field in vods + +module.exports = { + async up(knex) { + + console.log('2023.05.14 - migrate tags to tag_vod_relations') + + // get all tags_vods_links + // for each, create a tag-vod-relation + const tagsVodsLinks = await knex.select('*').from('tags_vods_links') + + for (const tvl of tagsVodsLinks) { + // Create a tag-vod-relation entry for each tag + const tvr = await knex('tag_vod_relations') + .insert({ + created_at: new Date(), + updated_at: new Date(), + creator_id: 1 + }) + .returning( + ['id'] + ) + + await knex('tag_vod_relations_tag_links').insert({ + tag_vod_relation_id: tvr[0].id, + tag_id: tvl.tag_id + }) + + await knex('tag_vod_relations_vod_links').insert({ + tag_vod_relation_id: tvr[0].id, + vod_id: tvl.vod_id + }) + } + + }, +}; \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023.05.15T02.44.00.000Z.drop-vod-tags.js b/packages/strapi/database/migrations/2023.05.15T02.44.00.000Z.drop-vod-tags.js new file mode 100644 index 0000000..15fc208 --- /dev/null +++ b/packages/strapi/database/migrations/2023.05.15T02.44.00.000Z.drop-vod-tags.js @@ -0,0 +1,12 @@ + +// previously, we tagged vods directly on the vod content-type +// now, we use tag-vod-relation to relate tags to vods. +// thus, we want to get rid of vod.tags +// and also tag.vods + +module.exports = { + async up(knex) { + console.log('2023.05.15 - drop tags_vods_links') + await knex.schema.dropTable('tags_vods_links') + } +} \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023.05.25-gimme-the-tags.js.noexec b/packages/strapi/database/migrations/2023.05.25-gimme-the-tags.js.noexec new file mode 100644 index 0000000..65af409 --- /dev/null +++ b/packages/strapi/database/migrations/2023.05.25-gimme-the-tags.js.noexec @@ -0,0 +1,110 @@ + +// const fetch = require('node-fetch') + +// // greets chatgpt +// async function getFileDetailsFromUrl(url) { +// const controller = new AbortController(); +// const signal = controller.signal; + +// const options = { +// signal, +// }; + +// let retries = 10; + +// while (retries) { +// console.log(`fetching ${url}`); +// const timeoutId = setTimeout(() => { +// console.log('fetch timed out, aborting...'); +// controller.abort(); +// }, 5000); + +// try { +// const res = await fetch(url, options); + +// clearTimeout(timeoutId); + +// console.log('finished fetch'); +// if (!res.ok) throw new Error(`problem while getting file from url with url ${url}`); +// if (!res?.headers?.get('x-bz-file-name')) throw new Error(`${url} did not have a x-bz-file-name in the response headers`); +// if (!res?.headers?.get('x-bz-file-id')) throw new Error(`${url} did not have a x-bz-file-id in the response headers`); + +// return { +// key: res.headers.get('x-bz-file-name'), +// url: url, +// uploadId: res.headers.get('x-bz-file-id'), +// }; +// } catch (err) { +// clearTimeout(timeoutId); +// retries--; + +// if (retries === 0) { +// console.error(`Could not fetch file details from URL: ${url}.`); +// throw err; +// } + +// console.warn(`Retrying fetch (${retries} attempts left)`); +// } +// } +// } + + + + +module.exports = { + async up(knex) { + // You have full access to the Knex.js API with an already initialized connection to the database + + + // we iterate through the local, non-strapi backup db first. + // get list of all tags + // for each tag + // * get list of related vods + // * create relation in Strapi + // * + + + + // Get all VODs from the database + const vods = await knex.select('*').from('vods'); + + + // Process each VOD + for (const vod of vods) { + + // courtesy timer + await new Promise((resolve) => setTimeout(resolve, 10)) + + // @todo + + console.log(vod) + // Get the file details from the VOD's video source URL + if (vod?.video_src) { + try { + const fileDetails = await getFileDetailsFromUrl(vod.video_src); + + // Insert the B2 file into the database + const [file] = await knex('b2_files').insert({ + url: fileDetails.url, + key: fileDetails.key, + upload_id: fileDetails.uploadId, + }).returning('id'); + + console.log(file) + console.log(`attempting to insert vod_id:${vod.id}, b_2_file_id:${file.id} for videoSrcB2`) + + // Link the B2 file to the VOD + await knex('vods_video_src_b_2_links').insert({ + vod_id: vod.id, + b_2_file_id: file.id, + }); + } catch (e) { + console.error(e) + console.log(`there was an error so we are skipping vod ${vod.id}`) + } + } else { + console.log(`${vod.id} has no video_src. skipping.`) + } + } + }, +}; \ No newline at end of file diff --git a/packages/strapi/database/migrations/2023.05.25T20.44.00.000Z.get-the-og-tags.js b/packages/strapi/database/migrations/2023.05.25T20.44.00.000Z.get-the-og-tags.js new file mode 100644 index 0000000..a211ebb --- /dev/null +++ b/packages/strapi/database/migrations/2023.05.25T20.44.00.000Z.get-the-og-tags.js @@ -0,0 +1,124 @@ +'use strict' + + +require('dotenv').config() + +const { Client } = require('pg') +const fetch = require('node-fetch') +const _ = require('lodash'); +const ogVods = require('../og-tags.json') + + +// const slugify = require('slugify') + + +// function slugifyString (str) { +// return slugify(str, { +// replacement: '-', // replace spaces with replacement character, defaults to `-` +// remove: undefined, // remove characters that match regex, defaults to `undefined` +// lower: true, // convert to lower case, defaults to `false` +// strict: true, // strip special characters except replacement, defaults to `false` +// locale: 'en', // language code of the locale to use +// trim: true // trim leading and trailing replacement chars, defaults to `true` +// }) +// } + + +async function associateTagWithVodsInStrapi (tagId, vodsIds) { + const res = await fetch(`${process.env.STRAPI_URL}/api/tags/${tagId}`, { + method: 'PUT', + headers: { + 'authorization': `Bearer ${process.env.STRAPI_API_KEY}` + }, + data: { + vods: [vodsIds] + } + }) + const json = await res.json() + + + if (!res.ok) throw new Error(JSON.stringify(json)) +} + + + +async function associateVodWithTagsInStrapi (knex, vodId, tagsIds) { + console.log(`updating vodId:${vodId} with tagsIds:${tagsIds}`) + for (const tagId of tagsIds) { + // see if it exists already + const rows = await knex.select('*').from('tags_vods_links').where({ + 'vod_id': vodId, + 'tag_id': tagId + }) + if (rows.length === 0) { + await knex('tags_vods_links').insert({ + vod_id: vodId, + tag_id: tagId + }); + } + } +} + +async function getStrapiVodByAnnounceUrl (knex, announceUrl) { + const rows = await knex.select('*').from('vods').where('announce_url', announceUrl) + return (rows[0]) +} + + + + +async function getStrapiTagByName (knex, tag) { + const rows = await knex.select('*').from('tags').where({ 'name': tag }) + return rows[0] +} + + + + + +module.exports = { + async up(knex) { + // You have full access to the Knex.js API with an already initialized connection to the database + + for (const vod of ogVods) { + // get matching vod in strapi + console.log(vod) + if (vod.announceUrl) { + const strapiVod = await getStrapiVodByAnnounceUrl(knex, vod.announceUrl) + + if (strapiVod) { + // we've got a matching vod + + if (vod.tags) { + console.log(`source vod has tags: ${vod.tags}`) + + let strapiTagsIds = [] + + // for each tag, get the matching strapi tag ID + for (const tag of vod.tags) { + // lookup the strapi tag id + const strapiTag = await getStrapiTagByName(knex, tag) + if (!!strapiTag) { + strapiTagsIds.push(strapiTag.id) + } + } + + console.log(`we are adding the following strapiTagsIds to vod ID ${strapiVod.id}: ${strapiTagsIds}`) + + // create relations between matching vod and the tags + await associateVodWithTagsInStrapi(knex, strapiVod.id, strapiTagsIds) + + } + } + } + } + + // Get all VODs from the database + const vods = await knex.select('*').from('vods'); + + // Process each VOD + for (const vod of vods) { + + } + } +} diff --git a/packages/strapi/database/migrations/2023.07.17.relate-vods-to-vtubers.js b/packages/strapi/database/migrations/2023.07.17.relate-vods-to-vtubers.js new file mode 100644 index 0000000..5f035a1 --- /dev/null +++ b/packages/strapi/database/migrations/2023.07.17.relate-vods-to-vtubers.js @@ -0,0 +1,70 @@ +module.exports = { + async up(knex) { + + console.log('Create vtubers table') + await knex.schema.createTable('vtubers', (table) => { + table.increments('id').primary(); + table.string('displayName').notNullable(); + table.string('chaturbate'); + table.string('twitter'); + table.string('patreon'); + table.string('twitch'); + table.string('tiktok'); + table.string('onlyfans'); + table.string('youtube'); + table.string('linktree'); + table.string('carrd'); + table.string('fansly'); + table.string('pornhub'); + table.string('discord'); + table.string('reddit'); + table.string('throne'); + table.string('instagram'); + table.string('facebook'); + table.string('merch'); + table.string('slug').notNullable(); + table.text('description1').notNullable(); + table.text('description2'); + table.string('image').notNullable(); + }); + + console.log('Create vods_vtuber_links table') + await knex.schema.createTable('vods_vtuber_links', (table) => { + table.increments('id').primary(); + table.integer('vod_id').unsigned().references('vods.id'); + table.integer('vtuber_id').unsigned().references('vtubers.id'); + table.integer('vod_order').notNullable(); + }); + + + console.log('Create a vtuber entry for ProjektMelody') + const projektMelody = { + displayName: 'ProjektMelody', + slug: 'projektmelody', // You can customize the slug based on your preference + description1: 'Description for ProjektMelody', // Add your vtuber's description here + image: 'http://futureporn-b2.b-cdn.net/futureporn/projekt-melody.jpg', // Replace with the image filename for ProjektMelody + }; + + console.log('Get all VODs from the database') + const vods = await knex.select('*').from('vods'); + + console.log('get projektmelody id') + // const [projektMelodyId] = await knex('vtubers').insert(projektMelody); + const projektMelodyId = 1 + + console.log(`projektmelodyId is : ${projektMelodyId}`) + + console.log(`For each VOD, associate ProjektMelody vtuber.`) + for (const [index, vod] of vods.entries()) { + console.log(`Check if vtuber_id exists in the vtubers table`) + const vtuber = await knex('vtubers').where('id', projektMelodyId).first(); + if (vtuber) { + await knex('vods_vtuber_links').insert({ + vtuber_id: projektMelodyId, + vod_id: vod.id, + vod_order: index + 1, // Auto-increment the vod_order number + }); + } + } + }, +}; diff --git a/packages/strapi/database/migrations/2023.07.31.add-b2-file-cdnUrl.js b/packages/strapi/database/migrations/2023.07.31.add-b2-file-cdnUrl.js new file mode 100644 index 0000000..308f159 --- /dev/null +++ b/packages/strapi/database/migrations/2023.07.31.add-b2-file-cdnUrl.js @@ -0,0 +1,18 @@ +module.exports = { + async up(knex) { + // Add the 'cdn_url' column to the 'b2_files' table + await knex.schema.table('b2_files', (table) => { + table.string('cdn_url'); // Change the data type if needed (e.g., text, varchar, etc.) + }); + + // Get all B2 Files from the database + const files = await knex.select('*').from('b2_files'); + + // For each B2 File, create cdnUrl + for (const [index, file] of files.entries()) { + const key = file.key; + const cdnUrl = `https://futureporn-b2.b-cdn.net/futureporn/${key}`; + await knex('b2_files').update({ cdn_url: cdnUrl }).where({ id: file.id }); + } + }, +}; diff --git a/packages/strapi/database/migrations/2024-01-08-add-streams.js.noexec b/packages/strapi/database/migrations/2024-01-08-add-streams.js.noexec new file mode 100644 index 0000000..d354107 --- /dev/null +++ b/packages/strapi/database/migrations/2024-01-08-add-streams.js.noexec @@ -0,0 +1,30 @@ +module.exports = { + async up(knex) { + + await knex.schema.createTable('streams', (table) => { + table.increments('id').primary(); + table.string('date_str').notNullable(); + table.string('vods'); + table.string('vtuber'); + table.string('tweet'); + table.string('date'); + table.string('cuid'); + }); + + + // Add the 'cdn_url' column to the 'b2_files' table + await knex.schema.table('b2_files', (table) => { + table.string('cdn_url'); // Change the data type if needed (e.g., text, varchar, etc.) + }); + + // Get all B2 Files from the database + const files = await knex.select('*').from('b2_files'); + + // For each B2 File, create cdnUrl + for (const [index, file] of files.entries()) { + const key = file.key; + const cdnUrl = `https://futureporn-b2.b-cdn.net/futureporn/${key}`; + await knex('b2_files').update({ cdn_url: cdnUrl }).where({ id: file.id }); + } + }, +}; diff --git a/packages/strapi/database/migrations/2024-01-14-add-date2-to-streams.js b/packages/strapi/database/migrations/2024-01-14-add-date2-to-streams.js new file mode 100644 index 0000000..5cbd5cf --- /dev/null +++ b/packages/strapi/database/migrations/2024-01-14-add-date2-to-streams.js @@ -0,0 +1,29 @@ + +module.exports = { + async up(knex) { + + console.log(`MIGRATION-- 2024-01-14-add-date2-to-streams.js`); + + // Check if the 'date_2' column already exists in the 'streams' table + const hasColumn = await knex.schema.hasColumn('streams', 'date_2'); + + if (!hasColumn) { + console.log(`Adding the 'date_2' column to the 'streams' table`); + await knex.schema.table('streams', (table) => { + table.string('date_2'); + }); + } + + // Get all streams from the database + const streams = await knex.select('*').from('streams'); + + // For each stream, populate date_2 if it's null or undefined + for (const [index, stream] of streams.entries()) { + if (stream.date_2 === null && stream.date_str !== null) { + const result = await knex('streams').update({ date_2: stream.date_str }).where({ id: stream.id }); + } + } + + }, +}; + diff --git a/packages/strapi/database/migrations/2024-01-15-add-platform-to-streams.js b/packages/strapi/database/migrations/2024-01-15-add-platform-to-streams.js new file mode 100644 index 0000000..a1632e9 --- /dev/null +++ b/packages/strapi/database/migrations/2024-01-15-add-platform-to-streams.js @@ -0,0 +1,49 @@ + +module.exports = { + async up(knex) { + + console.log(`MIGRATION-- 2024-01-15-add-platform-streams.js`); + + // Check if the 'platform' column already exists in the 'streams' table + const hasColumn = await knex.schema.hasColumn('streams', 'platform'); + + if (!hasColumn) { + console.log(`Adding the 'platform' column to the 'streams' table`); + await knex.schema.table('streams', (table) => { + table.string('platform'); + }); + } + + // Get all streams from the database + const streams = await knex.select('*').from('streams'); + + // For each stream, populate platform based on the related tweet data + for (const [index, stream] of streams.entries()) { + + const tweetLink = await knex('streams_tweet_links') + .where({ stream_id: stream.id }) + .first(); + + if (tweetLink) { + console.log(tweetLink); + + const tweet = await knex('tweets') + .where({ id: tweetLink.tweet_id }) + .first(); + + console.log(tweet); + + if (!!tweet) { + console.log(`stream ${stream.id} tweet tweet.is_chaturbate_invite=${tweet.is_chaturbate_invite}, tweet.is_fansly_invite=${tweet.is_fansly_invite}`); + await knex('streams').update({ + is_chaturbate_stream: !!tweet.is_chaturbate_invite, + is_fansly_stream: !!tweet.is_fansly_invite + }).where({ id: stream.id }); + } + } + + } + + }, +}; + diff --git a/packages/strapi/database/og-tags.json b/packages/strapi/database/og-tags.json new file mode 100644 index 0000000..7d838af --- /dev/null +++ b/packages/strapi/database/og-tags.json @@ -0,0 +1,1009 @@ +[{ + "tags": null, + "date": "2020-02-09T18:14:25.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1226570058073362438" +}, { + "tags": null, + "date": "2020-02-10T02:15:55.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1226691233659183104" +}, { + "tags": null, + "date": "2020-02-12T01:11:48.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1227399871734468610" +}, { + "tags": null, + "date": "2020-02-12T17:14:12.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1227642067733618691" +}, { + "tags": null, + "date": "2020-02-13T02:09:11.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1227776703415693312" +}, { + "tags": null, + "date": "2020-02-15T02:13:56.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1228502672246005760" +}, { + "tags": null, + "date": "2020-02-18T21:17:28.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1229877618067025923" +}, { + "tags": null, + "date": "2020-02-20T01:06:47.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1230297714719674368" +}, { + "tags": null, + "date": "2020-02-22T02:13:38.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1231039313300901889" +}, { + "tags": null, + "date": "2023-02-19T00:15:09.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1627099434508075008" +}, { + "tags": null, + "date": "2020-02-09T01:40:10.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1226319848411717632" +}, { + "tags": null, + "date": "2020-02-21T16:25:47.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1230891377422192641" +}, { + "tags": null, + "date": "2020-03-03T21:06:17.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1234948231311876096" +}, { + "tags": null, + "date": "2020-03-05T01:18:07.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1235373996906078208" +}, { + "tags": null, + "date": "2020-03-07T02:40:52.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1236119594995245057" +}, { + "tags": null, + "date": "2020-03-12T00:01:06.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1237891327922016258" +}, { + "tags": null, + "date": "2020-03-15T20:22:40.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1239285911201988609" +}, { + "tags": null, + "date": "2020-03-17T19:55:46.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1240003917355921412" +}, { + "tags": null, + "date": "2020-03-22T00:11:12.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1241517748276154371" +}, { + "tags": null, + "date": "2020-03-26T00:14:56.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1242968240894234627" +}, { + "tags": null, + "date": "2020-03-01T21:06:17.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1234223457723961344" +}, { + "tags": ["anal", "deep throat", "vore", "stuck in wall", "choking", "puddle", "squirting"], + "date": "2020-02-08T16:12:42.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1226177041109786625" +}, { + "tags": null, + "date": "2020-02-28T16:38:43.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1233431346112061441" +}, { + "tags": null, + "date": "2020-04-14T19:57:03.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1250151101174816769" +}, { + "tags": null, + "date": "2020-04-24T00:55:16.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1253487639430410240" +}, { + "tags": null, + "date": "2020-04-25T23:05:42.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1254184839684726786" +}, { + "tags": null, + "date": "2020-04-26T22:54:50.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1254544493849739264" +}, { + "tags": null, + "date": "2020-05-09T21:59:16.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1259241552016216065" +}, { + "tags": null, + "date": "2020-05-12T19:01:53.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1260284077011238912" +}, { + "tags": null, + "date": "2020-06-27T00:06:39.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1276668227767144452" +}, { + "tags": null, + "date": "2020-02-17T01:05:40.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1229210268447715328" +}, { + "tags": null, + "date": "2020-04-05T22:55:16.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1246934456927215617" +}, { + "tags": null, + "date": "2020-05-05T19:00:56.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1257747123682119680" +}, { + "tags": null, + "date": "2020-05-28T19:57:30.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1266096281002356736" +}, { + "tags": null, + "date": "2020-05-29T23:02:07.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1266505128426815488" +}, { + "tags": null, + "date": "2020-06-01T00:13:53.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1267247965464297473" +}, { + "tags": null, + "date": "2020-06-06T00:02:11.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1269056957295583240" +}, { + "tags": null, + "date": "2020-06-16T18:55:04.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1272965936685953024" +}, { + "tags": null, + "date": "2020-06-18T23:37:57.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1273761903664336897" +}, { + "tags": null, + "date": "2020-06-21T00:09:09.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1274494531312984064" +}, { + "tags": null, + "date": "2020-06-23T20:00:12.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1275519042955153409" +}, { + "tags": null, + "date": "2020-02-27T01:10:55.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1232835470712803328" +}, { + "tags": null, + "date": "2020-05-23T00:02:55.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1263983714893279233" +}, { + "tags": null, + "date": "2020-07-15T00:25:49.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1283196031648358400" +}, { + "tags": null, + "date": "2020-07-18T01:14:51.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1284295535751499776" +}, { + "tags": null, + "date": "2020-07-25T00:00:05.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1286813436119220225" +}, { + "tags": null, + "date": "2020-07-29T20:03:40.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1288565878502699008" +}, { + "tags": null, + "date": "2020-08-01T00:00:00.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1289350128436838400" +}, { + "tags": null, + "date": "2020-08-05T19:52:40.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1291099825396748292" +}, { + "tags": null, + "date": "2020-08-14T22:21:59.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1294398894466629634" +}, { + "tags": null, + "date": "2020-08-19T21:00:11.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1296190247957659649" +}, { + "tags": null, + "date": "2020-03-14T01:38:59.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1238640737668997120" +}, { + "tags": null, + "date": "2020-07-10T23:59:34.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1281739875319009280" +}, { + "tags": null, + "date": "2020-09-11T18:02:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1304480454242861056" +}, { + "tags": null, + "date": "2020-09-13T23:00:49.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1305280300130406402" +}, { + "tags": null, + "date": "2020-09-18T22:55:13.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1307090831011205120" +}, { + "tags": null, + "date": "2020-09-28T20:14:36.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1310674290744995843" +}, { + "tags": null, + "date": "2020-10-02T23:48:02.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1312177554824990720" +}, { + "tags": null, + "date": "2020-10-06T22:59:13.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1313614818713231360" +}, { + "tags": null, + "date": "2020-10-09T18:01:05.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1314626953803161601" +}, { + "tags": null, + "date": "2020-04-02T00:11:39.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1245504130753404931" +}, { + "tags": null, + "date": "2020-08-23T22:59:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1297669785883484160" +}, { + "tags": null, + "date": "2020-09-20T20:00:12.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1307771561651441665" +}, { + "tags": null, + "date": "2020-10-31T23:03:57.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1322675709379334146" +}, { + "tags": null, + "date": "2020-11-18T21:28:43.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1329174724790915075" +}, { + "tags": null, + "date": "2020-11-23T23:56:04.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1331023742693232646" +}, { + "tags": null, + "date": "2020-11-26T22:03:19.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1332082533526360066" +}, { + "tags": null, + "date": "2020-12-04T21:18:42.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1334970406025965571" +}, { + "tags": null, + "date": "2020-12-07T23:56:41.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1336097330483916800" +}, { + "tags": null, + "date": "2020-04-09T00:06:24.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1248039525215080449" +}, { + "tags": null, + "date": "2020-10-18T18:59:38.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1317903180148166657" +}, { + "tags": null, + "date": "2020-10-29T19:58:10.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1321904176637595648" +}, { + "tags": null, + "date": "2021-01-05T23:07:36.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1346594227233427459" +}, { + "tags": null, + "date": "2021-01-08T23:03:45.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1347680421920862209" +}, { + "tags": null, + "date": "2021-01-12T23:03:22.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1349129873068068873" +}, { + "tags": null, + "date": "2021-01-16T01:01:22.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1350246732391706624" +}, { + "tags": null, + "date": "2021-01-22T23:02:24.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1352753509045776384" +}, { + "tags": null, + "date": "2021-01-26T23:02:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1354203048491544581" +}, { + "tags": null, + "date": "2021-02-03T23:00:44.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1357101745961328645" +}, { + "tags": null, + "date": "2020-12-21T21:01:24.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1341126649329410050" +}, { + "tags": null, + "date": "2020-12-28T23:20:13.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1343698295563153408" +}, { + "tags": null, + "date": "2021-02-14T21:13:33.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1361061038620364812" +}, { + "tags": null, + "date": "2021-02-18T00:01:18.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1362190417631076352" +}, { + "tags": null, + "date": "2021-02-20T00:59:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1362929843759943681" +}, { + "tags": null, + "date": "2021-02-24T23:01:00.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1364711958105194498" +}, { + "tags": null, + "date": "2021-03-02T23:07:27.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1366887909241012233" +}, { + "tags": null, + "date": "2021-03-12T22:53:59.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1370508398081540098" +}, { + "tags": null, + "date": "2021-03-13T20:58:44.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1370841782506901516" +}, { + "tags": null, + "date": "2021-03-20T21:54:09.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1373392441257103368" +}, { + "tags": null, + "date": "2021-03-22T22:05:15.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1374120011854397442" +}, { + "tags": null, + "date": "2021-02-10T23:26:55.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1359645050792206337" +}, { + "tags": null, + "date": "2021-02-25T00:00:00.000Z", + "announceUrl": null +}, { + "tags": null, + "date": "2021-04-03T01:26:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1378156887884005378" +}, { + "tags": null, + "date": "2021-04-03T19:55:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1378436020081950726" +}, { + "tags": null, + "date": "2021-04-12T23:11:23.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1381746799506030593" +}, { + "tags": null, + "date": "2021-04-24T17:31:05.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1386009816443805699" +}, { + "tags": null, + "date": "2021-05-01T16:59:01.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1388538459816595463" +}, { + "tags": null, + "date": "2021-05-14T15:53:36.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1393233038298001411" +}, { + "tags": null, + "date": "2021-05-21T22:59:03.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1395876824865988611" +}, { + "tags": null, + "date": "2021-05-25T22:04:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1397312608454258690" +}, { + "tags": null, + "date": "2021-06-05T22:02:00.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1401298283499298817" +}, { + "tags": null, + "date": "2021-06-12T22:56:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1403848719695482888" +}, { + "tags": null, + "date": "2022-01-09T23:32:49.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1480321695315640328" +}, { + "tags": null, + "date": "2021-06-26T20:08:24.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1408879841315860486" +}, { + "tags": null, + "date": "2021-07-03T22:04:50.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1411445858244169728" +}, { + "tags": null, + "date": "2021-07-07T19:04:50.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1412850109608566786" +}, { + "tags": null, + "date": "2021-07-13T19:52:08.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1415036340580884482" +}, { + "tags": null, + "date": "2021-07-17T20:05:10.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1416489172311887876" +}, { + "tags": null, + "date": "2021-08-04T23:11:38.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1423059078381719564" +}, { + "tags": null, + "date": "2021-08-08T20:26:41.000Z", + "announceUrl": "http://twitter.com/ProjektMelody/status/1424467119006261249" +}, { + "tags": null, + "date": "2021-08-12T19:57:20.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1425909284940955648" +}, { + "tags": null, + "date": "2021-08-17T23:16:50.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1427771428833738755" +}, { + "tags": null, + "date": "2021-08-21T20:13:36.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1429174868935856133" +}, { + "tags": null, + "date": "2022-05-13T19:19:42.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1525194090299670528" +}, { + "tags": null, + "date": "2021-09-09T23:01:37.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1436102522201116673" +}, { + "tags": null, + "date": "2021-09-12T22:03:47.000Z", + "announceUrl": "http://twitter.com/ProjektMelody/status/1437175131223302146" +}, { + "tags": null, + "date": "2021-09-19T19:47:34.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1439677566739259395" +}, { + "tags": null, + "date": "2021-09-21T21:56:01.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1440434669305012228" +}, { + "tags": null, + "date": "2021-09-29T18:55:41.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1443288390007508995" +}, { + "tags": null, + "date": "2021-10-09T20:09:55.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1446930950332506117" +}, { + "tags": null, + "date": "2021-10-11T22:06:20.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1447685023059128320" +}, { + "tags": null, + "date": "2021-10-23T20:18:46.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1452006607626412038" +}, { + "tags": null, + "date": "2021-11-04T22:03:43.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1456381673235492878" +}, { + "tags": null, + "date": "2023-04-04T23:43:09.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1643398832485482497" +}, { + "tags": null, + "date": "2022-09-07T23:13:00.000Z", + "announceUrl": null +}, { + "tags": null, + "date": "2021-11-12T00:08:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1458949749986443268" +}, { + "tags": null, + "date": "2021-11-19T20:01:27.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1461786722337972235" +}, { + "tags": null, + "date": "2021-12-06T20:10:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1467949594252398599" +}, { + "tags": null, + "date": "2021-12-12T23:12:01.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1470169597546479617" +}, { + "tags": null, + "date": "2021-12-15T23:04:14.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1471254803451887625" +}, { + "tags": null, + "date": "2021-12-17T23:05:54.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1471979997426589699" +}, { + "tags": null, + "date": "2021-12-25T22:34:35.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1474871219610603526" +}, { + "tags": null, + "date": "2021-12-30T23:17:45.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1476694022408261640" +}, { + "tags": null, + "date": "2022-01-03T23:10:33.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1478141764741611527" +}, { + "tags": null, + "date": "2022-01-13T00:03:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1481416541493153795" +}, { + "tags": null, + "date": "2022-01-22T22:59:59.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1485024472105164805" +}, { + "tags": null, + "date": "2022-01-26T01:57:25.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1486156290585882626" +}, { + "tags": null, + "date": "2022-02-02T21:48:11.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1488992672022945793" +}, { + "tags": null, + "date": "2022-02-16T23:00:32.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1494084309958987782" +}, { + "tags": null, + "date": "2022-02-18T20:01:16.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1494763968535179274" +}, { + "tags": null, + "date": "2022-03-10T23:02:20.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1502057294246424600" +}, { + "tags": null, + "date": "2022-03-18T01:35:11.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1504632475804446721" +}, { + "tags": null, + "date": "2022-04-06T00:02:17.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1511494467097870340" +}, { + "tags": null, + "date": "2022-04-10T00:12:41.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1512946635655065602" +}, { + "tags": null, + "date": "2022-04-16T23:09:42.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1515467499642531845" +}, { + "tags": null, + "date": "2022-04-22T23:14:35.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1517643056228487168" +}, { + "tags": null, + "date": "2022-05-06T22:59:23.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1522712662349656064" +}, { + "tags": null, + "date": "2022-05-08T22:51:40.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1523435492657664000" +}, { + "tags": null, + "date": "2022-05-19T00:44:58.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1527087885693878275" +}, { + "tags": null, + "date": "2022-05-25T02:06:07.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1529282633762492417" +}, { + "tags": null, + "date": "2022-06-05T23:30:37.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1533592158179139585" +}, { + "tags": null, + "date": "2022-06-09T21:10:03.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1535006331119222784" +}, { + "tags": null, + "date": "2022-06-22T23:46:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1539756753147117568" +}, { + "tags": null, + "date": "2022-07-19T03:48:05.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1549239628418490368" +}, { + "tags": null, + "date": "2022-08-03T23:32:24.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1554973488871530501" +}, { + "tags": null, + "date": "2022-08-05T23:15:33.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1555694026376060966" +}, { + "tags": null, + "date": "2022-08-11T20:31:00.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1557827065101127680#m" +}, { + "tags": null, + "date": "2022-08-23T23:28:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1562220295683014657" +}, { + "tags": null, + "date": "2022-09-10T21:03:36.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1568706781806821377" +}, { + "tags": null, + "date": "2022-06-18T23:07:00.000Z", + "announceUrl": null +}, { + "tags": null, + "date": "2022-09-13T23:26:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1569829911057031170" +}, { + "tags": null, + "date": "2022-09-21T23:38:26.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1572732011529506817" +}, { + "tags": null, + "date": "2022-10-02T02:05:22.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1576392868201365504" +}, { + "tags": null, + "date": "2022-10-04T23:15:33.000Z", + "announceUrl": "http://twitter.com/ProjektMelody/status/1577437242716741632" +}, { + "tags": null, + "date": "2022-10-13T02:22:45.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1580383510908399616" +}, { + "tags": null, + "date": "2022-10-15T00:31:27.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1581080275546537984" +}, { + "tags": ["squirting", "toy", "lovense-lush", "fantasy-for-her-suction", "crave-vesper-necklace", "womanizer-duo", "twitching", "licking", "unboxing", "suction", "review", "orgasm", "tongue", "vibrator", "cum-eating", "pussy-milking", "torture", "rope", "restraint", "bondage", "chains", "dildo", "deep-breathing"], + "date": "2022-10-20T23:21:15.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1583236936445353985" +}, { + "tags": ["jumpsuit", "3d", "new-outfit", "pole-dancing", "lovense-lush", "lovense-hyfy", "blowjob", "bottomless", "ass", "dancing", "pool", "precum", "pussy"], + "date": "2022-11-04T20:29:53.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1588629627907473409" +}, { + "tags": ["video-game", "hentai", "eroge", "daraku-gear", "mobile-game", "buffering", "sponsored", "lovense-lush", "storytime", "hentai", "games", "gacha", "waifu", "womb-tattoo", "bondage", "mind-break", "squirting", "nipple-clamps", "handcuffs", "suction-cups", "prisoner", "slave", "big-brother", "master", "hitachi-magic-wand", "double-penetration", "confusion", "cock-block", "crying", "edging", "cum", "pasties", "robocock", "milking", "hose", "self-censorship", "threesome", "blowjob", "spanking", "cucking", "feet", "sex-training"], + "date": "2022-12-14T00:01:45.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1602816075304935425" +}, { + "tags": ["japan", "monokini", "outfit", "game-of-thrones", "gundam", "growling", "grinding", "flooring", "pool", "lovense-lush", "heavy-breathing", "moaning", "naked", "edging", "womb-tattoo", "pelvic-thrusting", "missionary", "fingering", "cum-drunk", "laughing", "c2c", "self-censorship", "sex-ed", "sex-stories", "glory-hole", "texting", "ass", "2-cooms", "orgasm", "selfie-cam", "non-euclidian", "silly-dancing"], + "date": "2022-12-16T00:30:34.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1603548101561696261" +}, { + "tags": ["silly-music", "guest-dj", "lovense-lush", "3d", "original-outfit", "step-brother", "buffering", "screen-recording", "coffee", "anime-discussion", "monday", "topless", "pasties", "just-chatting", "moaning", "countdown", "orgasm", "hands-free-cum", "yandere", "all-fours", "malfunctioning-hand", "bed", "high-heels", "boobs", "twerking", "factoid", "cowgirl", "unzip", "belly", "femdom", "gamer-chair", "selfie-camera", "pussy-licking", "pussy-closeup", "fingering", "aftershocks", "dakimakura-discussion", "2000s-music", "ghost-in-the-shell", "lgbtq", "spread-legs", "feet", "footjob", "tit-belt"], + "date": "2020-05-17T00:07:30.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1261810539442946049" +}, { + "tags": null, + "date": "2020-05-20T01:16:56.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1262915175037927425" +}, { + "tags": null, + "date": "2020-07-05T00:02:06.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1279566184690659330" +}, { + "tags": ["just-chatting", "chastity", "recovery", "live-2d", "fetish-research", "house-party", "video-game"], + "date": "2023-01-05T20:58:29.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1611104872484556805" +}, { + "tags": ["singing", "christmas", "karaoke", "just-chatting", "holiday", "lovense-lush", "tomboy", "live2d", "debut", "pegging", "asmr", "strapon-dildo", "erotic-roleplay", "edging"], + "date": "2023-01-13T18:21:13.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1613964398506147853" +}, { + "tags": ["just-chatting", "twintails", "g-string", "swimming-pool", "lovense-lush", "3d", "leap-motion", "orgasm-denial", "moaning", "bad-audio", "toilet", "bidet", "succubus-lingerie", "womb-tattoo", "dressup", "tv-discussion", "states-discussion", "naked", "experimental-lighting", "handjob", "asmr-discussion", "tease"], + "date": "2023-01-20T00:37:06.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1616233319439515648" +}, { + "tags": null, + "date": "2020-07-06T20:03:01.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1280230795085578247" +}, { + "tags": null, + "date": "2020-07-07T20:59:26.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1280607381605449729" +}, { + "tags": null, + "date": "2020-08-22T00:03:48.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1296961229475512323" +}, { + "tags": ["asmr", "3dio", "dominatrix", "submission", "breeding", "ai-dungeon", "tentacles", "anal", "fantasy-writing", "moaning", "cum-ban", "heavy-breathing", "orgasm", "denial", "cum-drunk"], + "date": "2023-01-21T20:36:06.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1616897448890273807" +}, { + "tags": null, + "date": "2020-09-05T23:08:12.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1302383058247614465" +}, { + "tags": null, + "date": "2021-07-04T18:50:39.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1411759377816690696" +}, { + "tags": ["bepis", "pasties", "recovery", "hentai-discussion", "topless", "boobs", "live2d", "handjob", "lovense-lush", "guilty-fap", "train-molestation", "sex-dungeon", "bdsm", "santa", "moaning", "mel-noises", "rickroll", "cucked", "cbat", "punishment2-cum", "orgasm", "milking-device", "cumdrunk", "panting", "heat", "feral", "deep-breathing", "monster-girl", "roleplay", "fucking", "gratitude", "snack", "meltys-quest", "eroge", "fat-bastard", "voiceover", "girl-on-girl", "thirsty", "slut", "good-audio", "love"], + "date": "2023-02-17T02:10:04.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1626403575642353665" +}, { + "tags": ["social-media", "aspirations", "first-time", "lovense-lush", "bad-audio", "ahegao", "3d", "lonely", "friends", "dab", "tag-discussion", "deep-breathing", "jerk-off-gesture", "gratitude", "cat-girls", "plexstorm", "hentai-game", "cute-pussy", "origin", "moaning", "twerking", "dancing", "shaking", "strip-tease", "anime-discussion", "video-game-discussion", "ass", "pasties", "high-heels", "harness", "thong", "leggings", "technical-difficulties", "panic-attack", "meditation", "just-chatting", "masturbation", "nipples"], + "date": "2020-02-07T23:21:48.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1225922638687752192" +}, { + "tags": null, + "date": "2020-10-16T23:32:50.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1317247159478136832" +}, { + "tags": ["bad-audio", "new-outfit", "anniversary", "bunny-outfit", "3d", "sexmachine", "raul", "recovery", "bunny-suit", "sit-on-face", "naked", "jacket", "mel-noises", "rickroll", "frickenator", "standing-cum", "legwarmers", "bunny-ears", "ass", "leg-shaking", "glitch", "doggy-style", "breeding", "dirty-talk", "cum-drunk", "trouble-walking", "slut", "food", "masturbation", "dick-sucking", "t1m", "lovense-lush", "pool", "flooring"], + "date": "2023-02-12T23:07:09.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1624907994671489026" +}, { + "tags": ["mel-noises", "crying", "good-audio", "live2d", "moaning", "dildo", "masturbation", "glass-dildo", "teaser", "sex-stories", "horny", "script-writing", "joi", "topless", "pasties", "naked", "asmr", "just-chatting", "self-care", "deep-breathing", "guided-meditation", "tantric-sex", "slut", "orgasm", "hentai-game", "caring"], + "date": "2023-02-09T00:25:47.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1623478232036122624" +}, { + "tags": ["just-chatting", "community", "anxiety", "dork", "live2d", "nut-between-worlds", "pro-social-behavior", "topless", "pasties", "harness", "boobs", "moaning", "blowjob"], + "date": "2023-02-13T03:07:00.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1624907994671489026" +}, { + "tags": null, + "date": "2020-03-01T01:14:02.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1233923415964340225" +}, { + "tags": null, + "date": "2020-03-28T01:42:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1243715015829585925" +}, { + "tags": null, + "date": "2020-04-01T00:13:26.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1245142190952796167" +}, { + "tags": null, + "date": "2020-05-02T22:01:33.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1256705414663725058" +}, { + "tags": null, + "date": "2020-06-10T18:58:26.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1270792457941368832" +}, { + "tags": null, + "date": "2020-06-28T00:10:07.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1277031489205678083" +}, { + "tags": null, + "date": "2020-02-25T21:31:41.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1232417908678168576" +}, { + "tags": null, + "date": "2020-03-10T22:00:51.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1237498680690855939" +}, { + "tags": null, + "date": "2020-03-20T15:41:36.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1241027117284044801" +}, { + "tags": null, + "date": "2020-08-28T23:03:16.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1299482712869306368" +}, { + "tags": null, + "date": "2020-09-24T23:25:58.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1309272896456527872" +}, { + "tags": null, + "date": "2020-10-13T23:03:14.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1316152545732157448" +}, { + "tags": null, + "date": "2020-10-23T18:55:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1319714083462107139" +}, { + "tags": ["just-chatting", "lovense-lush", "mel-noises", "pro-social-behavior", "topless", "g-string", "thong", "high-heels", "selfie-camera", "ass", "porn-discussion", "panties", "no-nut-november", "moaning", "feet", "feet-licking", "vtuber-discussion", "gamer-chair", "naked", "stretching", "flexibility", "orgasm", "masturbation"], + "date": "2020-11-09T00:55:59.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1325603004695842816" +}, { + "tags": null, + "date": "2020-12-14T21:46:27.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1338601272354758656" +}, { + "tags": null, + "date": "2020-12-24T23:02:13.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1342244216731275265" +}, { + "tags": null, + "date": "2020-08-11T23:01:22.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1293321639728492544" +}, { + "tags": null, + "date": "2020-10-11T22:05:30.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1315413239556190211" +}, { + "tags": null, + "date": "2021-03-03T23:03:48.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1367249376675041285" +}, { + "tags": null, + "date": "2021-04-18T00:30:03.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1383578535340634120" +}, { + "tags": null, + "date": "2021-04-23T23:01:52.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1385730672661303296" +}, { + "tags": null, + "date": "2021-06-06T20:55:39.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1401643974922838019" +}, { + "tags": null, + "date": "2021-06-15T22:57:44.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1404936189946236930" +}, { + "tags": null, + "date": "2021-07-27T23:04:15.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1420158120370872320" +}, { + "tags": null, + "date": "2021-08-30T23:08:30.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1432480377302552587" +}, { + "tags": null, + "date": "2021-09-03T21:59:09.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1433912474542264321" +}, { + "tags": null, + "date": "2021-02-05T22:57:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1357825669024120841" +}, { + "tags": null, + "date": "2021-10-04T22:20:50.000Z", + "announceUrl": "http://twitter.com/ProjektMelody/status/1445151953764458501" +}, { + "tags": null, + "date": "2021-11-16T23:07:06.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1460746277717782528" +}, { + "tags": null, + "date": "2021-12-22T23:58:43.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1473805228537438209" +}, { + "tags": null, + "date": "2022-01-15T20:53:35.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1482455948459450369" +}, { + "tags": null, + "date": "2022-01-28T22:13:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1487187063048921093" +}, { + "tags": null, + "date": "2022-02-13T21:17:23.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1492971187047374849" +}, { + "tags": null, + "date": "2022-04-13T20:10:50.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1514335322527649798" +}, { + "tags": null, + "date": "2022-05-14T23:02:18.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1525612497935507457" +}, { + "tags": null, + "date": "2022-05-26T18:31:28.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1529892992818876417" +}, { + "tags": null, + "date": "2022-08-01T19:28:31.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1554187338804338696" +}, { + "tags": null, + "date": "2022-09-02T20:06:16.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1565793017960828931" +}, { + "tags": null, + "date": "2022-09-23T23:38:18.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1573456755236954114" +}, { + "tags": ["memes", "anime", "bdsm", "orgasm", "moaning", "laughing", "cum-drunk", "lovense-lush"], + "date": "2022-10-27T23:20:05.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1585773359379677184" +}, { + "tags": ["lovense-hyphy", "lovense-lush", "whispering", "asmr", "sportscar", "pee", "sick", "coughing", "shaking", "cum", "orgasm", "brainfrog", "oral", "sucking", "sex-toy", "cum-drunk", "moaning", "twitching", "thrusting", "grinding", "objectification", "flooring", "keyboard-clicking", "mocap-failure", "muted", "charades", "posing", "riding", "dry-humping", "ass", "high-heels", "legs", "hip-sway", "plug-suit", "chest-harness", "naked", "womb-tattoo", "sexy-dance", "slav-squat", "tail", "pole-dance", "clipping", "licking", "glowing", "dab"], + "date": "2022-11-12T00:13:40.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1591222662487236609" +}, { + "tags": ["eroge", "video-game", "orc", "moaning", "cyber", "cum-drunk", "orgasm", "towel", "petting", "massage", "accupressure", "lingerie", "ass", "thong", "pasties", "adhesive-bandage", "monster-girls", "texting", "lovense-lush", "lovense-hyphy", "broken-toy", "tail", "live2d", "food-porn", "self-care", "aromatherapy", "monster-dick", "hot-sluts", "monster", "fantasy", "cock-sleeve", "feet", "footjob", "fingering", "elf", "cum-whore", "panties", "cum"], + "date": "2022-11-19T22:35:57.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1594097172689166337" +}, { + "tags": ["ass", "tail", "lovense-lush", "conversation", "grinding", "flooring", "going-away", "humping", "laughing", "eating", "harness", "boobs", "toes", "cum-drunk", "aftershocks", "dancing", "pole-dancing", "moaning", "feet", "stepping", "cock-abuse", "extreme-closeup", "butthole", "panties", "shaking", "pussy-licking", "singing", "hentai", "carnival", "fingering", "wedgie", "fetishwear", "watch-along", "pasties", "creampie", "rude-sex", "cumming", "g-string", "idol", "voyeurism", "condom", "gay", "spitting", "pervert", "edging", "mutual-masturbation", "sniffing"], + "date": "2022-11-22T23:04:43.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1595191577068044289" +}, { + "tags": ["hentai", "eroge", "dohna-dohna", "orgasm-denial", "chastity", "video-game", "live2d", "ovulation", "feet", "horny", "dream", "vampire", "just-chatting", "numi", "game-of-thrones", "recovery", "bad-audio", "yandere", "storytime", "spontaneous-song", "crushes"], + "date": "2023-01-01T22:03:49.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1609671764514164737" +}, { + "tags": null, + "date": "2021-09-07T22:01:24.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1435362590893527040" +}, { + "tags": null, + "date": "2021-09-16T20:00:39.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1438593697445302274" +}, { + "tags": null, + "date": "2022-02-07T23:27:14.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1490829536316985348" +}, { + "tags": ["lawnmower", "motorbunny", "bad-audio", "3d", "lovense-lush", "pussy-to-mouth", "screaming", "swimming-pool", "moaning", "deep-breathing", "cock-riding", "mel-noises", "mind-break", "technical-difficulties", "yelling", "echo", "reverse-cowgirl", "muffled-screams", "orgasm", "no-face-tracking", "swearing", "chaturbate-compliance", "spit", "messy", "cum-drinking", "clit-milking-device", "multiple-orgasms", "4-cum", "squirting", "loud-orgasm", "cum-chalice", "schlorp", "sksksksk"], + "date": "2023-01-25T00:19:39.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1618040869587947521" +}, { + "tags": ["lovense-lush", "lovense-hush", "tail", "butt-plug", "double-penetration", "moaning", "blushing", "manual-masturbation", "embarrasment", "orgasm", "just-chatting", "shorts", "stripping", "jiggle-physics", "3d", "boobs", "pasties", "dancing", "swearing", "vtuber-discussion", "t-pose", "ass", "anime-discussion", "selfie-camera", "edging", "jojo-posing", "anal-fingering", "fingering"], + "date": "2020-11-18T01:59:20.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1328880436551049217" +}, { + "tags": null, + "date": "2021-01-03T23:01:53.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1345868009483735042" +}, { + "tags": null, + "date": "2021-01-10T19:57:37.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1348358353676873729" +}, { + "tags": null, + "date": "2021-02-07T23:04:03.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1358552132295798787" +}, { + "tags": ["boobs", "porn-game", "video-game", "bondage", "mobile-game", "android", "moaning", "bikini", "blowjob", "doggy-style", "3d", "sponsored"], + "date": "2022-11-18T01:03:45.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1593409592477638656" +}, { + "tags": ["lovense-lush", "spanking", "ass", "stripping", "bottom-bitch", "permission", "cum-slut", "blowjob", "grinding", "yoga", "cyber", "kegel", "conversation", "bend-over", "squirming", "horny", "dildo-choking", "deep-throating", "finger-stimulation", "begging", "hummer", "orgasm", "cum-drunk", "laughing", "gratitude", "teasing", "crying", "bullying", "moaning", "feet-licking", "womb-tattoo", "harness", "boobs", "vagina", "drugs", "interpretive-dance", "sexy-dancing", "pole-dancing", "flooring", "jig", "silly-dancing", "tail"], + "date": "2022-11-22T01:15:55.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1594862203546501121" +}, { + "tags": ["love-drunk", "noises", "x-mas", "technical-difficulties", "horny", "chat-analytics", "chat-roast", "lovense-lush", "moaning", "self-deprecation-humor", "edging", "just-chatting", "orgasm", "2-cum", "cum-drunk", "multiple-orgasms", "hands-free-cum", "handcuffs", "butt-plug", "anal", "chastity-device", "bdsm", "ball-gag", "collar", "heavy-breathing", "big-o", "snacking", "fat-bastard", "airplane", "mile-high-club", "dirty-talk", "video-game", "malady", "hime-hajime", "big-bang-studios", "a-nut-between-worlds"], + "date": "2022-12-17T22:14:20.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1604238592389238784" +}, { + "tags": ["video-game", "recovery", "bad-audio", "technical-difficulties", "just-chatting", "purino-party", "zooted", "medication", null], + "date": "2023-01-05T02:07:18.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1610820355895037955" +}, { + "tags": ["cum-ban", "soaking", "semantics", "lovense-lush", "dildo", "pussy-noises", "relaxation", "meditation", "games", "meltys-quest", "wet", "asmr", "messy", "moaning", "horny", "blowjob", "doctor-defiance", "cum-brain", "mind-break", "giggles", "mel-noises", "hentai-game", "prank", "ear-rape", "live2d", "voice-acting", "goblin-sex", "drake__selfsuck"], + "date": "2023-01-09T23:07:18.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1612586844298018817" +}, { + "tags": ["bondage", "digital-art", "lovense-lush", "mel-noises", "moaning", "hucow", "pussy-milking", "browser-wars", "edging", "daddy-play", "orgasm", "feet", "live2d", "rapping", "singing", "vibrator", "good-audio", "vibrator-asmr", "spreader-bars", "precum", "puddle", "bdsm", "restraint", "slut", "cum-drunk", "loud-orgasm", "multiple-orgasms", "screaming", "begging", "cum-inside", "mind-break", "aftershocks", "labia-spreader"], + "date": "2023-01-29T22:06:21.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1619819262125944832" +}, { + "tags": ["pasties", "topless", "live2d", "lovense-lush", "lovense-osci", "orgasm", "cum-drunk", "mind-break", "moaning", "mel-noises", "multiple-orgasms", "hentai-watch-along", "dirty-talk", "blowjob", "horny", "tentacles", "double-penetration", "fantasy"], + "date": "2023-02-01T01:38:32.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1620597436988411907" +}, { + "tags": ["cooking", "onigiri", "seiso", "irl", "bad-audio", "rickroll", "dirty-slut", "crepe", "jokes", "just-chatting", "sex-toy-discussion", "booli", "femdom", "topless", "apron", "boobs", "nipples", "bacon", "innuendo", "dirty-talk", "parenting", "stories"], + "date": "2023-02-24T00:17:38.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1628911998787362817" +}, { + "tags": ["3d", "lovense-lush", "lovense-sex-machine", "pool", "dancing", "sway", "ass", "shorts", "leggings", "middrift", "mel-noises", "pig-latin", "moaning", "topless", "bra", "bottomless", "good-audio", "kneeling", "legs-spread", "t1m", "pussy", "grinding", "shaking", "missionary-style", "first-time", "lovense-hyphy", "cum", "orgasm", "multiple-orgasms", "begging", "prone-bone", "daddy", "dirty-talk", "shibari", "closeup", "pov", "6-cums"], + "date": "2023-03-04T02:28:14.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1631843966843863040" +}, { + "tags": ["anime-girl", "horny", "baka", "lovense-lush", "boobs", "harness", "pasties", "cum", "orgasm", "edging", "gunrun", "hormones", "subathon", "fantasy", "sake", "ntrpg", "game-night", "toilet", "ntr", "elderly", "fatherboard", "sneeze", "dream-daddy", "futa-fix-dick-dine-and-dash", "futanari", "asmr", "lovense-hush", "anal", "begging"], + "date": "2023-03-03T02:04:05.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1631475499753979908" +}, { + "tags": ["lovense-partnership", "big-bang-a-nut-between-worlds", "selfcest", "cuck", "video-game", "sponsored-stream", "finger-blasting", "blowjob", "handjob"], + "date": "2023-03-24T21:13:29.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1639374902246404102" +}, { + "tags": ["bikini", "womb-tattoo", "pool", "lovense-lush", "japan", "adhd", "edging", "permission", "cum-slut", "mel-noises", "moaning", "begging", "yelling", "mindbreak", "cum-drunk", "dirty-talk", "bbc", "dildo", "death", "lovense-sex-machine", "bakery", "orgasms", "multiple-orgasms", "ejaculate", "blowjob", "just-chatting", "choking", "joi", "slave", "master", "deepthroat", "sloppy", "3d", "goblin", "anal-breeding", "rapping", "dj", "dancing", "hime-hajime", "bad-audio", "voice-acting", "alto"], + "date": "2023-03-23T21:13:04.000Z", + "announceUrl": "https://twitter.com/ProjektMelody/status/1639012409175072769" +}] \ No newline at end of file diff --git a/packages/strapi/favicon.png b/packages/strapi/favicon.png new file mode 100644 index 0000000..df668a8 Binary files /dev/null and b/packages/strapi/favicon.png differ diff --git a/packages/strapi/misc/2023-05-26-export-og-tags.js b/packages/strapi/misc/2023-05-26-export-og-tags.js new file mode 100644 index 0000000..522e3e8 --- /dev/null +++ b/packages/strapi/misc/2023-05-26-export-og-tags.js @@ -0,0 +1,202 @@ +require('dotenv').config() + +const { Client } = require('pg') +const fetch = require('node-fetch') +const _ = require('lodash'); + + + +// module.exports = { +// async up(knex) { + +// // Get all VODs from the database +// const vods = await knex.select('*').from('vods'); + +// // sanity check every B2 URL +// for (const vod of vods) { +// await checkUrl(vod.video_src) +// } + +// console.log(`there are ${problemUrls.length} the problem urls`) +// console.log(problemUrls) + +// process.exit(5923423) +// }, +// }; + +// const slugify = require('slugify') + + +// function slugifyString (str) { +// return slugify(str, { +// replacement: '-', // replace spaces with replacement character, defaults to `-` +// remove: undefined, // remove characters that match regex, defaults to `undefined` +// lower: true, // convert to lower case, defaults to `false` +// strict: true, // strip special characters except replacement, defaults to `false` +// locale: 'en', // language code of the locale to use +// trim: true // trim leading and trailing replacement chars, defaults to `true` +// }) +// } + + +async function associateTagWithVodsInStrapi (tagId, vodsIds) { + const res = await fetch(`${process.env.STRAPI_URL}/api/tags/${tagId}`, { + method: 'PUT', + headers: { + 'authorization': `Bearer ${process.env.STRAPI_API_KEY}` + }, + data: { + vods: [vodsIds] + } + }) + const json = await res.json() + + + if (!res.ok) throw new Error(JSON.stringify(json)) +} + + + +async function associateVodWithTagsInStrapi (vodId, tagsIds) { + const res = await fetch(`${process.env.STRAPI_URL}/api/vods/${vodId}?populate=*`, { + method: 'PUT', + headers: { + 'authorization': `Bearer ${process.env.STRAPI_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + data: { + tags: tagsIds + } + }) + }) + const json = await res.json() + + + if (!res.ok) throw new Error(JSON.stringify(json)) +} + +async function getStrapiVodByAnnounceUrl (announceUrl) { + const res = await fetch(`${process.env.STRAPI_URL}/api/vods?filters[announceUrl][$eqi]=${announceUrl}`, { + method: 'GET', + headers: { + 'authorization': `Bearer ${process.env.STRAPI_API_KEY}` + } + }) + const json = await res.json() + return json.data[0] +} + + +// async function getStrapiVodByDate (date) { + +// // const r = await fetch(`${process.env.STRAPI_URL}/api/vods/200`, { +// // method: 'GET', +// // headers: { +// // 'authorization': `Bearer ${process.env.STRAPI_API_KEY}` +// // } +// // }) +// // const j = await r.json() + +// const res = await fetch(`${process.env.STRAPI_URL}/api/vods?filters[date][$eqi]=${date}`, { +// method: 'GET', +// headers: { +// 'authorization': `Bearer ${process.env.STRAPI_API_KEY}` +// } +// }) +// const json = await res.json() +// process.exit(3) +// if (!res.ok) throw new Error(JSON.stringify(json)); +// return json.id +// } + + +async function getStrapiTagByName (tag) { + const res = await fetch(`${process.env.STRAPI_URL}/api/tags?filters[name][$eqi]=${tag}`, { + method: 'GET', + headers: { + 'authorization': `Bearer ${process.env.STRAPI_API_KEY}` + } + }) + const json = await res.json() + return json.data[0] +} + +async function main () { + const client = new Client() + await client.connect() + + + // get list of vods from our source db + const vodsResponse = await client.query('SELECT tags, date, "announceUrl" FROM vod') + + console.log(JSON.stringify(vodsResponse.rows)) + process.exit(5) + + for (const vod of vodsResponse.rows) { + + + + // get matching vod in strapi + const strapiVod = await getStrapiVodByAnnounceUrl(vod.announceUrl) + + if (strapiVod) { + // we've got a matching vod + + if (vod.tags) { + console.log(`source vod has tags: ${vod.tags}`) + + let strapiTagsIds = [] + + // for each tag, get the matching strapi tag ID + for (const tag of vod.tags) { + // lookup the strapi tag id + const strapiTag = await getStrapiTagByName(tag) + strapiTagsIds.push(strapiTag.id) + } + + console.log(`we are adding the following strapiTagsIds to vod ID ${strapiVod.id}: ${strapiTagsIds}`) + + // create relations between matching vod and the tags + await associateVodWithTagsInStrapi(strapiVod.id, strapiTagsIds) + + } + } + } + + // const groupedCollection = _.groupBy(related.rows, 'vod_id'); + // for (const vodId in groupedCollection) { + // const tagsIds = groupedCollection[vodId].map((t)=>t.tag_id) + // const tagsIdsAltered = tagsIds.map((t)=>(t === 114) ? 520 : t) + // await associateVodWithTagsInStrapi(vodId, tagsIdsAltered) + // } + + // const res = await client.query('SELECT id, name FROM tags') + // for (const tag of res.rows) { + // // for (const link of related.rows) { + // // await new Promise((resolve) => setTimeout(resolve, 1000)) + // // await associateTagWithVodsInStrapi(link.tag_id, link.vod_id) + // // } + // } + + await client.end() +} + + + + + +// const res = await client.query('SELECT * FROM public.tags_vod_links') + + +main() +// restore tags from db backup +// make associations between vods <--> tags in strapi + + +// we iterate through the local, non-strapi backup db first. +// get list of all tags +// for each tag +// * get list of related vods +// * create relation in Strapi. + diff --git a/packages/strapi/misc/generateCuid.js b/packages/strapi/misc/generateCuid.js new file mode 100644 index 0000000..c049924 --- /dev/null +++ b/packages/strapi/misc/generateCuid.js @@ -0,0 +1,7 @@ +const { init } = require('@paralleldrive/cuid2'); + +module.exports = function() { + const length = 10; + const genCuid = init({ length }); + return genCuid(); +} \ No newline at end of file diff --git a/packages/strapi/package.json b/packages/strapi/package.json new file mode 100644 index 0000000..764c23f --- /dev/null +++ b/packages/strapi/package.json @@ -0,0 +1,90 @@ +{ + "name": "futureporn-strapi", + "private": true, + "version": "0.1.0", + "description": "A Strapi application", + "scripts": { + "dev:c": "concurrently \"npm:tunnel\" \"npm:dev:strapi\"", + "tunnel": "ngrok start futureporn-strapi", + "chisel": "bash ./chisel.sh", + "db": "bash ./database/devDb.sh", + "start": "strapi start", + "build": "strapi build", + "develop": "NODE_ENV=development strapi develop", + "strapi": "strapi", + "preinstall": "npx only-allow yarn" + }, + "dependencies": { + "@11ty/eleventy-fetch": "^4.0.0", + "@aws-sdk/client-s3": "^3.485.0", + "@esm2cjs/execa": "6.1.1-cjs.1", + "@mux/mux-node": "^7.3.3", + "@paralleldrive/cuid2": "^2.2.2", + "@radix-ui/react-use-callback-ref": "^1.0.1", + "@strapi/plugin-i18n": "4.17.0", + "@strapi/plugin-users-permissions": "4.17.0", + "@strapi/provider-email-sendgrid": "4.17.0", + "@strapi/provider-upload-cloudinary": "4.17.0", + "@strapi/strapi": "4.17.0", + "@strapi/utils": "4.17.0", + "@testing-library/dom": "8.19.0", + "@testing-library/react": "12.1.4", + "@testing-library/react-hooks": "8.0.1", + "@testing-library/user-event": "14.4.3", + "aws-sdk": "^2.1539.0", + "bcryptjs": "2.4.3", + "better-sqlite3": "8.0.1", + "canvas": "^2.11.2", + "codemirror": "^6.0.1", + "css-loader": "^6.8.1", + "cuid": "^3.0.0", + "date-fns": "^3.1.0", + "formik": "2.2.9", + "fuzzy-search": "^3.2.1", + "grant-koa": "5.4.8", + "history": "^4.10.1", + "immer": "9.0.19", + "jsonwebtoken": "9.0.0", + "jwk-to-pem": "2.0.5", + "koa": "^2.15.0", + "koa2-ratelimit": "^1.1.3", + "lodash": "4.17.21", + "match-sorter": "^4.2.1", + "msw": "1.0.1", + "node-abort-controller": "^3.1.1", + "object-assign": "^4.1.1", + "pg": "^8.11.3", + "prop-types": "^15.8.1", + "purest": "4.0.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-intl": "6.3.2", + "react-query": "3.24.3", + "react-redux": "8.0.5", + "react-router-dom": "5.3.4", + "react-test-renderer": "^17.0.2", + "semver": "^7.5.4", + "sharp": "^0.32.6", + "strapi-plugin-fuzzy-search": "^2.2.0", + "styled-components": "5.3.3", + "typescript": "^5.3.3", + "url-join": "4.0.1", + "yallist": "^4.0.0", + "yup": "^0.32.11" + }, + "devDependencies": { + "concurrently": "^8.2.2" + }, + "author": { + "name": "CJ_Clippy" + }, + "strapi": { + "uuid": false + }, + "engines": { + "node": ">=14.19.1 <=18.x.x", + "npm": ">=6.0.0" + }, + "license": "MIT", + "packageManager": "yarn@1.22.19" +} diff --git a/packages/strapi/public/robots.txt b/packages/strapi/public/robots.txt new file mode 100644 index 0000000..ff5d316 --- /dev/null +++ b/packages/strapi/public/robots.txt @@ -0,0 +1,3 @@ +# To prevent search engines from seeing the site altogether, uncomment the next two lines: +# User-Agent: * +# Disallow: / diff --git a/packages/strapi/public/uploads/.gitkeep b/packages/strapi/public/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/strapi/src/admin/app.example.js b/packages/strapi/src/admin/app.example.js new file mode 100644 index 0000000..45cad61 --- /dev/null +++ b/packages/strapi/src/admin/app.example.js @@ -0,0 +1,39 @@ +const config = { + locales: [ + // 'ar', + // 'fr', + // 'cs', + // 'de', + // 'dk', + // 'es', + // 'he', + // 'id', + // 'it', + // 'ja', + // 'ko', + // 'ms', + // 'nl', + // 'no', + // 'pl', + // 'pt-BR', + // 'pt', + // 'ru', + // 'sk', + // 'sv', + // 'th', + // 'tr', + // 'uk', + // 'vi', + // 'zh-Hans', + // 'zh', + ], +}; + +const bootstrap = (app) => { + console.log(app); +}; + +export default { + config, + bootstrap, +}; diff --git a/packages/strapi/src/admin/webpack.config.example.js b/packages/strapi/src/admin/webpack.config.example.js new file mode 100644 index 0000000..1ca45c2 --- /dev/null +++ b/packages/strapi/src/admin/webpack.config.example.js @@ -0,0 +1,9 @@ +'use strict'; + +/* eslint-disable no-unused-vars */ +module.exports = (config, webpack) => { + // Note: we provide webpack above so you should not `require` it + // Perform customizations to webpack config + // Important: return the modified config + return config; +}; diff --git a/packages/strapi/src/api/.gitkeep b/packages/strapi/src/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/strapi/src/api/b2-file/content-types/b2-file/schema.json b/packages/strapi/src/api/b2-file/content-types/b2-file/schema.json new file mode 100644 index 0000000..6992f65 --- /dev/null +++ b/packages/strapi/src/api/b2-file/content-types/b2-file/schema.json @@ -0,0 +1,36 @@ +{ + "kind": "collectionType", + "collectionName": "b2_files", + "info": { + "singularName": "b2-file", + "pluralName": "b2-files", + "displayName": "B2 File", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "url": { + "type": "string", + "required": true, + "unique": true + }, + "key": { + "type": "string", + "unique": true, + "required": true + }, + "uploadId": { + "type": "string", + "unique": true, + "required": true + }, + "cdnUrl": { + "type": "string", + "unique": true, + "required": true + } + } +} diff --git a/packages/strapi/src/api/b2-file/controllers/b2-file.js b/packages/strapi/src/api/b2-file/controllers/b2-file.js new file mode 100644 index 0000000..65f936a --- /dev/null +++ b/packages/strapi/src/api/b2-file/controllers/b2-file.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * b2-file controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::b2-file.b2-file'); diff --git a/packages/strapi/src/api/b2-file/routes/b2-file.js b/packages/strapi/src/api/b2-file/routes/b2-file.js new file mode 100644 index 0000000..a74a8d9 --- /dev/null +++ b/packages/strapi/src/api/b2-file/routes/b2-file.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * b2-file router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::b2-file.b2-file'); diff --git a/packages/strapi/src/api/b2-file/services/b2-file.js b/packages/strapi/src/api/b2-file/services/b2-file.js new file mode 100644 index 0000000..03e2bdf --- /dev/null +++ b/packages/strapi/src/api/b2-file/services/b2-file.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * b2-file service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::b2-file.b2-file'); diff --git a/packages/strapi/src/api/contributor/content-types/contributor/schema.json b/packages/strapi/src/api/contributor/content-types/contributor/schema.json new file mode 100644 index 0000000..3cc046c --- /dev/null +++ b/packages/strapi/src/api/contributor/content-types/contributor/schema.json @@ -0,0 +1,30 @@ +{ + "kind": "collectionType", + "collectionName": "contributors", + "info": { + "singularName": "contributor", + "pluralName": "contributors", + "displayName": "Contributor", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "name": { + "type": "string", + "required": true + }, + "url": { + "type": "string" + }, + "isFinancialDonor": { + "type": "boolean", + "default": false + }, + "isVodProvider": { + "type": "boolean" + } + } +} diff --git a/packages/strapi/src/api/contributor/controllers/contributor.js b/packages/strapi/src/api/contributor/controllers/contributor.js new file mode 100644 index 0000000..22b9cf6 --- /dev/null +++ b/packages/strapi/src/api/contributor/controllers/contributor.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * contributor controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::contributor.contributor'); diff --git a/packages/strapi/src/api/contributor/routes/contributor.js b/packages/strapi/src/api/contributor/routes/contributor.js new file mode 100644 index 0000000..cf61a59 --- /dev/null +++ b/packages/strapi/src/api/contributor/routes/contributor.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * contributor router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::contributor.contributor'); diff --git a/packages/strapi/src/api/contributor/services/contributor.js b/packages/strapi/src/api/contributor/services/contributor.js new file mode 100644 index 0000000..ca75442 --- /dev/null +++ b/packages/strapi/src/api/contributor/services/contributor.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * contributor service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::contributor.contributor'); diff --git a/packages/strapi/src/api/goal/content-types/goal/schema.json b/packages/strapi/src/api/goal/content-types/goal/schema.json new file mode 100644 index 0000000..0580c6f --- /dev/null +++ b/packages/strapi/src/api/goal/content-types/goal/schema.json @@ -0,0 +1,30 @@ +{ + "kind": "collectionType", + "collectionName": "goals", + "info": { + "singularName": "goal", + "pluralName": "goals", + "displayName": "Goal", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "amountCents": { + "type": "string", + "required": true, + "unique": true + }, + "description": { + "type": "string", + "required": true, + "unique": true + }, + "name": { + "type": "string", + "unique": true + } + } +} diff --git a/packages/strapi/src/api/goal/controllers/goal.js b/packages/strapi/src/api/goal/controllers/goal.js new file mode 100644 index 0000000..2b8e3a3 --- /dev/null +++ b/packages/strapi/src/api/goal/controllers/goal.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * goal controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::goal.goal'); diff --git a/packages/strapi/src/api/goal/routes/goal.js b/packages/strapi/src/api/goal/routes/goal.js new file mode 100644 index 0000000..e836728 --- /dev/null +++ b/packages/strapi/src/api/goal/routes/goal.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * goal router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::goal.goal'); diff --git a/packages/strapi/src/api/goal/services/goal.js b/packages/strapi/src/api/goal/services/goal.js new file mode 100644 index 0000000..6c047ac --- /dev/null +++ b/packages/strapi/src/api/goal/services/goal.js @@ -0,0 +1,11 @@ +'use strict'; + +/** + * goal service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::goal.goal'); + + diff --git a/packages/strapi/src/api/gogs/content-types/gogs/schema.json b/packages/strapi/src/api/gogs/content-types/gogs/schema.json new file mode 100644 index 0000000..d933d5d --- /dev/null +++ b/packages/strapi/src/api/gogs/content-types/gogs/schema.json @@ -0,0 +1,24 @@ +{ + "kind": "singleType", + "collectionName": "gogss", + "info": { + "singularName": "gogs", + "pluralName": "gogss", + "displayName": "Gogs" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "apiKey": { + "type": "string", + "required": true + }, + "url": { + "type": "string", + "required": true, + "default": "https://git.futureporn.net" + } + } +} diff --git a/packages/strapi/src/api/gogs/controllers/gogs.js b/packages/strapi/src/api/gogs/controllers/gogs.js new file mode 100644 index 0000000..5d83874 --- /dev/null +++ b/packages/strapi/src/api/gogs/controllers/gogs.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * gogs controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::gogs.gogs', ({ strapi }) => ({ + issues: async (ctx) => { + try { + // Fetch the 'gogs' single type from Strapi + const gogsConfig = await strapi.query('api::gogs.gogs').findOne(); + + if (!gogsConfig) { + return ctx.badRequest('Gogs configuration not found'); + } + + const { url, apiKey } = gogsConfig; + const openIssues = await strapi.service('api::gogs.gogs').fetchAllPagesFromGogsAPI(`${url}/api/v1/repos/futureporn/pm/issues?state=open`, apiKey) + const closedIssues = await strapi.service('api::gogs.gogs').fetchAllPagesFromGogsAPI(`${url}/api/v1/repos/futureporn/pm/issues?state=closed`, apiKey) + + return { openIssues, closedIssues } + } catch (error) { + console.error('Error fetching Gogs issues:', error); + return ctx.badRequest('Failed to fetch issues from Gogs'); + } + } +})); \ No newline at end of file diff --git a/packages/strapi/src/api/gogs/routes/gogs.js b/packages/strapi/src/api/gogs/routes/gogs.js new file mode 100644 index 0000000..50c6544 --- /dev/null +++ b/packages/strapi/src/api/gogs/routes/gogs.js @@ -0,0 +1,34 @@ +'use strict'; + +/** + * gogs router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +const defaultRouter = createCoreRouter('api::gogs.gogs'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "GET", + path: "/gogs/issues", + handler: "api::gogs.gogs.issues" + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); + diff --git a/packages/strapi/src/api/gogs/services/gogs.js b/packages/strapi/src/api/gogs/services/gogs.js new file mode 100644 index 0000000..13afb2f --- /dev/null +++ b/packages/strapi/src/api/gogs/services/gogs.js @@ -0,0 +1,42 @@ +'use strict'; + +/** + * gogs service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + + + +module.exports = createCoreService('api::gogs.gogs', ({ strapi }) => ({ + + async fetchAllPagesFromGogsAPI(url, apiKey) { + + // Fetch the first page + const response = await fetch(url, { + headers: { + 'Authorization': `token ${apiKey}` + } + }); + + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + + const data = await response.json(); + + // Check if there are more pages available + if (response.headers.has('link')) { + const linkHeader = response.headers.get('link'); + const nextPageMatch = /<([^>]+)>;\s*rel="next"/.exec(linkHeader); + + if (nextPageMatch) { + const nextPageUrl = nextPageMatch[1]; + const nextPageData = await this.fetchAllPagesFromGogsAPI(nextPageUrl, apiKey); + return [...data, ...nextPageData]; + } + } + + return data; + } +})) \ No newline at end of file diff --git a/packages/strapi/src/api/issue/content-types/issue/schema.json b/packages/strapi/src/api/issue/content-types/issue/schema.json new file mode 100644 index 0000000..b8c6768 --- /dev/null +++ b/packages/strapi/src/api/issue/content-types/issue/schema.json @@ -0,0 +1,36 @@ +{ + "kind": "collectionType", + "collectionName": "issues", + "info": { + "singularName": "issue", + "pluralName": "issues", + "displayName": "issue", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "url": { + "type": "string", + "required": true + }, + "sla": { + "type": "enumeration", + "enum": [ + "public", + "patron", + "authenticated" + ], + "default": "public", + "required": true + }, + "type": { + "type": "enumeration", + "enum": [ + "stall" + ] + } + } +} diff --git a/packages/strapi/src/api/issue/controllers/issue.js b/packages/strapi/src/api/issue/controllers/issue.js new file mode 100644 index 0000000..a9c83e3 --- /dev/null +++ b/packages/strapi/src/api/issue/controllers/issue.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * issue controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::issue.issue'); diff --git a/packages/strapi/src/api/issue/routes/issue.js b/packages/strapi/src/api/issue/routes/issue.js new file mode 100644 index 0000000..146d9fe --- /dev/null +++ b/packages/strapi/src/api/issue/routes/issue.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * issue router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::issue.issue'); diff --git a/packages/strapi/src/api/issue/services/issue.js b/packages/strapi/src/api/issue/services/issue.js new file mode 100644 index 0000000..9782a38 --- /dev/null +++ b/packages/strapi/src/api/issue/services/issue.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * issue service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::issue.issue'); diff --git a/packages/strapi/src/api/mux-asset/content-types/mux-asset/schema.json b/packages/strapi/src/api/mux-asset/content-types/mux-asset/schema.json new file mode 100644 index 0000000..736ba34 --- /dev/null +++ b/packages/strapi/src/api/mux-asset/content-types/mux-asset/schema.json @@ -0,0 +1,28 @@ +{ + "kind": "collectionType", + "collectionName": "mux_assets", + "info": { + "singularName": "mux-asset", + "pluralName": "mux-assets", + "displayName": "Mux Asset", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "playbackId": { + "type": "string", + "required": false, + "unique": true + }, + "assetId": { + "type": "string", + "unique": true + }, + "deletionQueuedAt": { + "type": "datetime" + } + } +} diff --git a/packages/strapi/src/api/mux-asset/controllers/mux-asset.js b/packages/strapi/src/api/mux-asset/controllers/mux-asset.js new file mode 100644 index 0000000..e02adbc --- /dev/null +++ b/packages/strapi/src/api/mux-asset/controllers/mux-asset.js @@ -0,0 +1,56 @@ +'use strict'; + +const { JWT } = require('@mux/mux-node'); + +const MUX_SIGNING_KEY_ID = process.env.MUX_SIGNING_KEY_ID; +const MUX_SIGNING_KEY_PRIVATE_KEY = process.env.MUX_SIGNING_KEY_PRIVATE_KEY; +const MUX_PLAYBACK_RESTRICTION_ID = process.env.MUX_PLAYBACK_RESTRICTION_ID + +if (!MUX_SIGNING_KEY_PRIVATE_KEY) throw new Error('MUX_SIGNING_KEY_PRIVATE_KEY must be defined in env'); +if (!MUX_SIGNING_KEY_ID) throw new Error('MUX_SIGNING_KEY_ID must be defined in env'); +if (!MUX_PLAYBACK_RESTRICTION_ID) throw new Error('MUX_PLAYBACK_RESTRICTION_ID must be defined in env'); + + +/** + * mux-asset controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::mux-asset.mux-asset', ({ strapi }) => ({ + + async secure (ctx, next) { + + if (ctx?.query?.id === undefined) { + ctx.throw(400, 'id query param was missing!') + return + } + + const tokens = {} + + tokens.playbackToken = JWT.signPlaybackId(ctx.query.id, { + keyId: MUX_SIGNING_KEY_ID, + keySecret: MUX_SIGNING_KEY_PRIVATE_KEY, + params: { + playback_restriction_id: MUX_PLAYBACK_RESTRICTION_ID + }, + }) + + + tokens.storyboardToken = JWT.signPlaybackId(ctx.query.id, { + keyId: MUX_SIGNING_KEY_ID, + keySecret: MUX_SIGNING_KEY_PRIVATE_KEY, + type: 'storyboard' + }) + + tokens.thumbnailToken = JWT.signPlaybackId(ctx.query.id, { + keyId: MUX_SIGNING_KEY_ID, + keySecret: MUX_SIGNING_KEY_PRIVATE_KEY, + type: 'thumbnail' + }) + + + ctx.body = tokens + } +})) + diff --git a/packages/strapi/src/api/mux-asset/routes/mux-asset.js b/packages/strapi/src/api/mux-asset/routes/mux-asset.js new file mode 100644 index 0000000..2dc38eb --- /dev/null +++ b/packages/strapi/src/api/mux-asset/routes/mux-asset.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * mux-asset router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +const defaultRouter = createCoreRouter('api::mux-asset.mux-asset'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "GET", + path: "/mux-asset/secure", + handler: "mux-asset.secure" + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); \ No newline at end of file diff --git a/packages/strapi/src/api/mux-asset/services/mux-asset.js b/packages/strapi/src/api/mux-asset/services/mux-asset.js new file mode 100644 index 0000000..a4cbcb5 --- /dev/null +++ b/packages/strapi/src/api/mux-asset/services/mux-asset.js @@ -0,0 +1,78 @@ +'use strict'; + +const { JWT } = require('@mux/mux-node'); +const { createCoreService } = require('@strapi/strapi').factories; + +/** + * mux-asset service + */ + + +module.exports = createCoreService('api::mux-asset.mux-asset', ({strapi}) => ({ + + + /** + * reference: https://docs.mux.com/guides/video/secure-video-playback#4-generate-a-json-web-token-jwt + * reference: https://docs.mux.com/guides/video/secure-video-playback#5-sign-the-json-web-token-jwt + * + * @param {String} playbackId - signed playback ID + * @param {String} keyId - signing key ID + * @param {String} keySecret - base64 encoded private key + * @param {String} playbackRestructionId - https://docs.mux.com/guides/video/secure-video-playback#create-a-playback-restriction + * @returns {Object} jwt - + * @returns {String} jwt.token - + * @returns {String} jwt.gifToken - + * @returns {String} jwt.thumbnailToken - + */ + async signJwt (playbackId, keyId, keySecret, playbackRestrictionId) { + // Set some base options we can use for a few different signing types + // Type can be either video, thumbnail, gif, or storyboard + let baseOptions = { + keyId: keyId, // Enter your signing key id here + keySecret: keySecret, // Enter your base64 encoded private key here + expiration: '7d' // E.g 60, "2 days", "10h", "7d", numeric value interpreted as seconds + }; + + const playbackToken = JWT.signPlaybackId(playbackId, { + ...baseOptions , + type: 'video', + params: { playback_restriction_id: playbackRestrictionId } + }); + + // Now the signed playback url should look like this: + // https://stream.mux.com/${playbackId}.m3u8?token=${token} + + // If you wanted to pass in params for something like a gif, use the + // params key in the options object + // const gifToken = JWT.signPlaybackId(playbackId, { + // ...baseOptions, + // type: 'gif', + // params: { time: 10 }, + // }) + + const thumbnailToken = JWT.signPlaybackId(playbackId, { + type: 'thumbnail', + params: { playback_restriction_id: playbackRestrictionId }, + }) + + // Then, use this token in a URL like this: + // https://image.mux.com/${playbackId}/animated.gif?token=${gifToken} + + // A final example, if you wanted to sign a thumbnail url with a playback restriction + const storyboardToken = JWT.sign(playbackId, { + ...baseOptions, + type: 'storyboard', + params: { playback_restriction_id: playbackRestrictionId }, + }) + + // When used in a URL, it should look like this: + // https://image.mux.com/${playbackId}/thumbnail.png?token=${thumbnailToken} + + return { + playbackToken, + storyboardToken, + thumbnailToken + } + }, + +})); \ No newline at end of file diff --git a/packages/strapi/src/api/patreon/content-types/patreon/schema.json b/packages/strapi/src/api/patreon/content-types/patreon/schema.json new file mode 100644 index 0000000..a76213c --- /dev/null +++ b/packages/strapi/src/api/patreon/content-types/patreon/schema.json @@ -0,0 +1,39 @@ +{ + "kind": "singleType", + "collectionName": "patreons", + "info": { + "singularName": "patreon", + "pluralName": "patreons", + "displayName": "Patreon", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "benefitId": { + "type": "string", + "required": true, + "default": "4760169" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "campaignId": { + "type": "string", + "required": true + }, + "muxAllocationCostCents": { + "type": "integer", + "default": 50, + "required": true + }, + "membershipId": { + "type": "string" + } + } +} diff --git a/packages/strapi/src/api/patreon/controllers/patreon.js b/packages/strapi/src/api/patreon/controllers/patreon.js new file mode 100644 index 0000000..c06944a --- /dev/null +++ b/packages/strapi/src/api/patreon/controllers/patreon.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * patreon controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::patreon.patreon', ({ strapi }) => ({ + async getPublicPatrons(ctx) { + const patrons = await strapi.entityService.findMany('plugin::users-permissions.user', { + fields: ['username', 'vanityLink', 'isNamePublic', 'isLinkPublic', 'patreonBenefits'], + }) + + let publicPatrons = [] + for (const patron of patrons) { + let publicPatron = {} + let benefits = (!!patron?.patreonBenefits) ? patron.patreonBenefits.split(',') : [] + if (patron.isNamePublic) publicPatron.username = patron.username; + + // if patron has "Your URL displayed on Futureporn.net" benefit, + // publically share their link if they want it shared + if (benefits.includes('10663202')) { + if (patron.isLinkPublic) publicPatron.vanityLink = patron.vanityLink; + } + + if (!!publicPatron.username || !!publicPatron.vanityLink) publicPatrons.push(publicPatron); + } + + return publicPatrons + }, + async muxAllocationCount(ctx) { + const count = await strapi.service('api::patreon.patreon').getMuxAllocationCount() + return count + } +})); + diff --git a/packages/strapi/src/api/patreon/routes/patreon.js b/packages/strapi/src/api/patreon/routes/patreon.js new file mode 100644 index 0000000..52af951 --- /dev/null +++ b/packages/strapi/src/api/patreon/routes/patreon.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * patreon router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +const defaultRouter = createCoreRouter('api::patreon.patreon'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "GET", + path: "/patreon/patrons", + handler: "api::patreon.patreon.getPublicPatrons" + }, { + method: 'GET', + path: '/patreon/muxAllocationCount', + handler: 'api::patreon.patreon.muxAllocationCount' + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); \ No newline at end of file diff --git a/packages/strapi/src/api/patreon/services/patreon.js b/packages/strapi/src/api/patreon/services/patreon.js new file mode 100644 index 0000000..147d2a4 --- /dev/null +++ b/packages/strapi/src/api/patreon/services/patreon.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * patreon service + */ + +const EleventyFetch = require("@11ty/eleventy-fetch"); +const { createCoreService } = require('@strapi/strapi').factories; + + + +module.exports = createCoreService('api::patreon.patreon', ({strapi}) => ({ + + + async getPatreonCampaign() { + return EleventyFetch('https://www.patreon.com/api/campaigns/8012692', { + duration: "12h", + type: "json", + }) + }, + + + async getPatreonCampaignPledgeSum() { + const campaign = await this.getPatreonCampaign() + return campaign.data.attributes.pledge_sum + }, + + + /** + * Calculate how many mux allocations the site should have, based on the dollar amount of pledges from patreon + * + * @param {Number} pledgeSum - USD cents + */ + async getMuxAllocationCount() { + const patreonData = await strapi.entityService.findMany('api::patreon.patreon', { + fields: ['muxAllocationCostCents'] + }) + if (!patreonData) throw new Error('patreonData in Strapi was missing'); + const muxAllocationCostCents = patreonData.muxAllocationCostCents + const pledgeSum = await this.getPatreonCampaignPledgeSum() + const muxAllocationCount = Math.floor(pledgeSum / muxAllocationCostCents); // calculate the number of mux allocations required + return muxAllocationCount; + } +})); + diff --git a/packages/strapi/src/api/profile/controllers/profile.js b/packages/strapi/src/api/profile/controllers/profile.js new file mode 100644 index 0000000..610a88d --- /dev/null +++ b/packages/strapi/src/api/profile/controllers/profile.js @@ -0,0 +1,21 @@ +'use strict'; + + + +module.exports = { + update: async (ctx, next) => { + const update = strapi.plugin('users-permissions').controllers.user.update + await update(ctx); + }, + me: async (ctx) => { + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + const user = await strapi.entityService.findOne('plugin::users-permissions.user', userId, { + populate: 'role' + }); + return user + }, + test: async (ctx) => { + return 'blah' + } +}; diff --git a/packages/strapi/src/api/profile/routes/profile.js b/packages/strapi/src/api/profile/routes/profile.js new file mode 100644 index 0000000..7a33f1c --- /dev/null +++ b/packages/strapi/src/api/profile/routes/profile.js @@ -0,0 +1,23 @@ +module.exports = { + routes: [ + { + method: 'PUT', + path: '/profile/:id', + handler: 'profile.update', + config: { + prefix: '', + policies: ['global::updateOwnerOnly'] + }, + }, + { + method: 'GET', + path: '/profile/me', + handler: 'profile.me' + }, + { + method: 'GET', + path: '/profile/test', + handler: 'profile.test' + } + ], +}; diff --git a/packages/strapi/src/api/profile/services/profile.js b/packages/strapi/src/api/profile/services/profile.js new file mode 100644 index 0000000..56ac091 --- /dev/null +++ b/packages/strapi/src/api/profile/services/profile.js @@ -0,0 +1,7 @@ +'use strict'; + +/** + * profile service + */ + +module.exports = () => ({}); diff --git a/packages/strapi/src/api/stream/content-types/stream/lifecycles.js b/packages/strapi/src/api/stream/content-types/stream/lifecycles.js new file mode 100644 index 0000000..661c14e --- /dev/null +++ b/packages/strapi/src/api/stream/content-types/stream/lifecycles.js @@ -0,0 +1,61 @@ +const { init } = require('@paralleldrive/cuid2'); + + + +module.exports = { + async beforeUpdate(event) { + const { data } = event.params; + if (!data.cuid) { + const length = 10; // 50% odds of collision after ~51,386,368 ids + const cuid = init({ length }); + event.params.data.cuid = cuid(); + } + }, + async afterUpdate(event) { + console.log(`>>>>>>>>>>>>>> STREAM is afterUpdate !!!!!!!!!!!!`); + + const { data, where, select, populate } = event.params; + + console.log(data); + + const id = where.id; + + // greets https://forum.strapi.io/t/how-to-get-previous-component-data-in-lifecycle-hook/25892/4?u=ggr247 + const existingData = await strapi.entityService.findOne("api::stream.stream", id, { + populate: ['vods', 'tweet'] + }) + + // Initialize archiveStatus to a default value + let archiveStatus = 'missing'; + + // Iterate through all vods to determine archiveStatus + for (const vod of existingData.vods) { + if (!!vod.videoSrcHash) { + if (!!vod.note) { + // If a vod has both videoSrcHash and note, set archiveStatus to 'issue' + archiveStatus = 'issue'; + break; // No need to check further + } else { + // If a vod has videoSrcHash but no note, set archiveStatus to 'good' + archiveStatus = 'good'; + } + } + } + + // we can't use query engine here, because that would trigger an infinite loop + // where this + // instead we access knex instance + await strapi.db.connection("streams").where({ id: id }).update({ + archive_status: archiveStatus, + }); + + if (!!existingData.tweet) { + await strapi.db.connection("streams").where({ id: id }).update({ + is_chaturbate_stream: existingData.tweet.isChaturbateInvite, + is_fansly_stream: existingData.tweet.isFanslyInvite + }); + } + + + } +}; \ No newline at end of file diff --git a/packages/strapi/src/api/stream/content-types/stream/schema.json b/packages/strapi/src/api/stream/content-types/stream/schema.json new file mode 100644 index 0000000..1506582 --- /dev/null +++ b/packages/strapi/src/api/stream/content-types/stream/schema.json @@ -0,0 +1,73 @@ +{ + "kind": "collectionType", + "collectionName": "streams", + "info": { + "singularName": "stream", + "pluralName": "streams", + "displayName": "Stream", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "date_str": { + "type": "string", + "required": true, + "unique": true, + "regex": "\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d+([+-][0-2]\\d:[0-5]\\d|Z)" + }, + "date2": { + "type": "string", + "required": true, + "unique": true, + "regex": "\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d+([+-][0-2]\\d:[0-5]\\d|Z)" + }, + "vods": { + "type": "relation", + "relation": "oneToMany", + "target": "api::vod.vod", + "mappedBy": "stream" + }, + "vtuber": { + "type": "relation", + "relation": "manyToOne", + "target": "api::vtuber.vtuber", + "inversedBy": "streams" + }, + "tweet": { + "type": "relation", + "relation": "oneToOne", + "target": "api::tweet.tweet" + }, + "date": { + "type": "datetime", + "required": true, + "unique": true + }, + "archiveStatus": { + "type": "enumeration", + "enum": [ + "missing", + "issue", + "good" + ], + "required": true, + "default": "missing" + }, + "cuid": { + "type": "string", + "unique": true, + "required": false + }, + "isChaturbateStream": { + "type": "boolean", + "default": false + }, + "isFanslyStream": { + "type": "boolean", + "default": false + } + } +} diff --git a/packages/strapi/src/api/stream/controllers/stream.js b/packages/strapi/src/api/stream/controllers/stream.js new file mode 100644 index 0000000..aaee46e --- /dev/null +++ b/packages/strapi/src/api/stream/controllers/stream.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * stream controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::stream.stream'); diff --git a/packages/strapi/src/api/stream/routes/stream.js b/packages/strapi/src/api/stream/routes/stream.js new file mode 100644 index 0000000..6af6923 --- /dev/null +++ b/packages/strapi/src/api/stream/routes/stream.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * stream router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::stream.stream'); diff --git a/packages/strapi/src/api/stream/services/stream.js b/packages/strapi/src/api/stream/services/stream.js new file mode 100644 index 0000000..b0311da --- /dev/null +++ b/packages/strapi/src/api/stream/services/stream.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * stream service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::stream.stream'); diff --git a/packages/strapi/src/api/tag-vod-relation/content-types/tag-vod-relation/schema.json b/packages/strapi/src/api/tag-vod-relation/content-types/tag-vod-relation/schema.json new file mode 100644 index 0000000..129ef5a --- /dev/null +++ b/packages/strapi/src/api/tag-vod-relation/content-types/tag-vod-relation/schema.json @@ -0,0 +1,39 @@ +{ + "kind": "collectionType", + "collectionName": "tag_vod_relations", + "info": { + "singularName": "tag-vod-relation", + "pluralName": "tag-vod-relations", + "displayName": "Tag Vod Relation", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "votes": { + "type": "integer" + }, + "creator": { + "type": "relation", + "relation": "oneToOne", + "target": "plugin::users-permissions.user" + }, + "tag": { + "type": "relation", + "relation": "oneToOne", + "target": "api::tag.tag" + }, + "creatorId": { + "type": "integer", + "required": true + }, + "vod": { + "type": "relation", + "relation": "manyToOne", + "target": "api::vod.vod", + "inversedBy": "tagVodRelations" + } + } +} diff --git a/packages/strapi/src/api/tag-vod-relation/controllers/tag-vod-relation.js b/packages/strapi/src/api/tag-vod-relation/controllers/tag-vod-relation.js new file mode 100644 index 0000000..8face45 --- /dev/null +++ b/packages/strapi/src/api/tag-vod-relation/controllers/tag-vod-relation.js @@ -0,0 +1,222 @@ +'use strict'; + +/** + * tag-vod-relation controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::tag-vod-relation.tag-vod-relation', ({ strapi }) => ({ + async relate(ctx) { + + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.body.data) return ctx.badRequest('data was missing from body'); + if (!ctx.request.body.data.tag) return ctx.badRequest('tag was missing from data'); + if (!ctx.request.body.data.vod) return ctx.badRequest('vod was missing from data'); + + const { tag: tagId, vod: vodId } = ctx.request.body.data; + + const tagVodRelation = await strapi.entityService.create('api::tag-vod-relation.tag-vod-relation', { + data: { + vod: vodId, + tag: tagId, + creator: userId, + creatorId: userId, + publishedAt: new Date(), + votes: 2 + } + }) + + return tagVodRelation + }, + async vote(ctx) { + // @todo + }, + + // // greets https://docs.strapi.io/dev-docs/backend-customization/controllers#extending-core-controllers + // // greets https://docs.strapi.io/dev-docs/backend-customization/controllers#adding-a-new-controller + // // Method 2: Wrapping a core action (leaves core logic in place) + // async find(ctx) { + // // // some custom logic here + // // ctx.query = { ...ctx.query, local: 'en' } + + // const userId = ctx?.state?.user?.id; + // if (!userId) return ctx.badRequest("There was no user id in the request!"); + + + + // // Calling the default core action + // const { data, meta } = await super.find(ctx); + + // // add isCreator if the tvr was created by this user + // let dataWithCreator = data.map((d) => { + // if (d.data.attributes.) + // }) + + // // // some more custom logic + // // meta.date = Date.now() + + // return { data, meta }; + // }, + + // greets https://docs.strapi.io/dev-docs/backend-customization/controllers#extending-core-controllers + // greets https://docs.strapi.io/dev-docs/backend-customization/controllers#adding-a-new-controller + // Method 2: Wrapping a core action (leaves core logic in place) + async create(ctx) { + console.log('>> create a tag vod relation') + // only allow unique tag, vod combos + + const { query } = ctx.request; + + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.body.data) return ctx.badRequest('data was missing from body'); + if (!ctx.request.body.data.tag) return ctx.badRequest('tag was missing from data'); + if (!ctx.request.body.data.vod) return ctx.badRequest('vod was missing from data'); + + const { tag: tagId, vod: vodId } = ctx.request.body.data; + + console.log(`lets make a combo entityService.findMany`) + const combo = await strapi.entityService.findMany('api::tag-vod-relation.tag-vod-relation', { + populate: ['tag', 'vod'], + filters: { + $and: [{ + tag: { + id: { + $eq: ctx.request.body.data.tag + } + } + }, { + vod: { + id: { + $eq: ctx.request.body.data.vod + } + } + }] + } + }) + + if (combo.length > 0) { + return ctx.badRequest('this vod already has that tag'); + } + + // @todo add votes and creator + ctx.request.body.data.creator = userId + ctx.request.body.data.votes = 2 + + const parseBody = (ctx) => { + if (ctx.is('multipart')) { + return parseMultipartData(ctx); + } + + const { data } = ctx.request.body || {}; + + return { data }; + }; + + + const sanitizedInputData = { + vod: vodId, + tag: tagId, + publishedAt: new Date(), + creator: userId, + creatorId: userId, + votes: 2 + } + + + + + + const entity = await strapi + .service('api::tag-vod-relation.tag-vod-relation') + .create({ + ...query, + data: sanitizedInputData, + populate: { vod: true, tag: true } + }); + + const sanitizedEntity = await this.sanitizeOutput(entity, ctx); + + return this.transformResponse({ ...sanitizedEntity }); + }, + + + async tagVod (ctx) { + + // create tag if needed + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.body.data) return ctx.badRequest('data was missing from body'); + if (!ctx.request.body.data.tagName) return ctx.badRequest('tagName was missing from data'); + if (!ctx.request.body.data.vodId) return ctx.badRequest('vodId was missing from data'); + + const { tagName, vodId } = ctx.request.body.data; + + + const tag = await strapi.service('api::tag.tag').assertTag(tagName, userId); + + + try { + const tvr = await strapi.service('api::tag-vod-relation.tag-vod-relation').assertTvr(tag.id, vodId, userId); + + + const sanitizedEntity = await this.sanitizeOutput(tvr, ctx); + return this.transformResponse({ ...sanitizedEntity }); + } catch (e) { + console.error(e) + ctx.badRequest('Vod Tag could not be created.') + } + + }, + + async deleteMine (ctx) { + // // some custom logic here + // ctx.query = { ...ctx.query, local: 'en' } + + + + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.params.id) return ctx.badRequest('id was missing from params'); + const { id } = ctx.request.params; + + // constraints + // only able to delete tagVodRelation if + // * creator + // * publishedAt isBefore(now-24h) + + + // get the tvr the user wants to delete + const tvrToDelete = await strapi.entityService.findOne('api::tag-vod-relation.tag-vod-relation', id, { + populate: { + tag: true, + vod: true, + creator: true, + } + }) + + if (!tvrToDelete) return ctx.badRequest('Tag to be deleted does not exist.'); + + if (tvrToDelete.creator.id !== userId) + ctx.forbidden('only the creator of the tag can delete it'); + + if ((new Date(tvrToDelete.createdAt).valueOf()+86400000) < new Date().valueOf()) + ctx.forbidden('cannot delete tags older than 24 hours') + + // Calling the default core action + const { data, meta } = await super.delete(ctx); + + // delete the related tag if it has no other vod + // @todo?? or maybe this is handled by lifecycle hook? + + // // some more custom logic + // meta.date = Date.now() + + return { data, meta }; + } + + +})); + diff --git a/packages/strapi/src/api/tag-vod-relation/routes/tag-vod-relation.js b/packages/strapi/src/api/tag-vod-relation/routes/tag-vod-relation.js new file mode 100644 index 0000000..2564f0f --- /dev/null +++ b/packages/strapi/src/api/tag-vod-relation/routes/tag-vod-relation.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * tag-vod-relation router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +const defaultRouter = createCoreRouter('api::tag-vod-relation.tag-vod-relation'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "POST", + path: "/tag-vod-relations/relate", + handler: "api::tag-vod-relation.tag-vod-relation.relate" + }, + // { + // method: 'GET', + // path: '/tag-vod-relations', + // handler: 'api::tag-vod-relation.tag-vod-relation.find' + // }, + { + method: "PUT", + path: "/tag-vod-relations/vote", + handler: "api::tag-vod-relation.tag-vod-relation.vote" + }, { + method: 'POST', + path: '/tag-vod-relations/tag', + handler: 'api::tag-vod-relation.tag-vod-relation.tagVod' + }, { + method: 'DELETE', + path: '/tag-vod-relations/deleteMine/:id', + handler: 'api::tag-vod-relation.tag-vod-relation.deleteMine' + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); \ No newline at end of file diff --git a/packages/strapi/src/api/tag-vod-relation/services/tag-vod-relation.js b/packages/strapi/src/api/tag-vod-relation/services/tag-vod-relation.js new file mode 100644 index 0000000..e958bbc --- /dev/null +++ b/packages/strapi/src/api/tag-vod-relation/services/tag-vod-relation.js @@ -0,0 +1,69 @@ +'use strict'; + +/** + * tag-vod-relation service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::tag-vod-relation.tag-vod-relation', ({ strapi }) => ({ + + + async assertTvr(tagId, vodId, userId) { + + if (!tagId) throw new Error('tagId was missing in request'); + if (!vodId) throw new Error('vodId was missing in request'); + if (!userId) throw new Error('userId was missing in request'); + + + + let existingTvr; + existingTvr = await strapi.entityService + .findMany('api::tag-vod-relation.tag-vod-relation', { + limit: 1, + filters: { + $and: [ + { + tag: { + id: tagId, + }, + }, + { + vod: { + id: vodId + } + } + ] + }, + populate: ['tag', 'vod'] + }) + + + if (existingTvr.length === 0) { + const newTvr = await strapi.entityService.create('api::tag-vod-relation.tag-vod-relation', { + data: { + tag: tagId, + vod: vodId, + creator: userId, + creatorId: userId, + }, + populate: { + tag: true, + vod: true + } + }) + + // trigger data revalidation in next.js server + // fetch(`${nextJsServerUrl}/`) + return newTvr; + } else { + + return existingTvr[0]; + } + + + }, + +})); + + diff --git a/packages/strapi/src/api/tag/content-types/tag/schema.json b/packages/strapi/src/api/tag/content-types/tag/schema.json new file mode 100644 index 0000000..2884619 --- /dev/null +++ b/packages/strapi/src/api/tag/content-types/tag/schema.json @@ -0,0 +1,38 @@ +{ + "kind": "collectionType", + "collectionName": "tags", + "info": { + "singularName": "tag", + "pluralName": "tags", + "displayName": "Tag", + "description": "" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": {}, + "attributes": { + "name": { + "type": "string", + "unique": true, + "required": true + }, + "toy": { + "type": "relation", + "relation": "manyToOne", + "target": "api::toy.toy", + "inversedBy": "tags" + }, + "vods": { + "type": "relation", + "relation": "manyToMany", + "target": "api::vod.vod", + "inversedBy": "tags" + }, + "creator": { + "type": "relation", + "relation": "oneToOne", + "target": "plugin::users-permissions.user" + } + } +} diff --git a/packages/strapi/src/api/tag/controllers/tag.js b/packages/strapi/src/api/tag/controllers/tag.js new file mode 100644 index 0000000..51669a9 --- /dev/null +++ b/packages/strapi/src/api/tag/controllers/tag.js @@ -0,0 +1,87 @@ +'use strict'; + +/** + * tag controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; +const { sanitize } = require('@strapi/utils'); + + +module.exports = createCoreController('api::tag.tag', ({ strapi }) => ({ + + async random(ctx) { + const numberOfTags = 10; // Change this number to the desired number of random tags + const contentType = strapi.contentType('api::vod.vod'); + + // Fetch only the 'id' field of all tags + const tagIds = (await strapi.entityService.findMany( + "api::tag.tag", + { + fields: ['id'], + } + )).map(tag => tag.id); + + + const selectedTags = []; + + // Randomly select the specified number of tag IDs + for (let i = 0; i < numberOfTags; i++) { + const randomIndex = Math.floor(Math.random() * tagIds.length); + const randomTagId = tagIds[randomIndex]; + + + // Fetch the full details of the randomly selected tag using its ID + const rawTag = await strapi.entityService.findOne( + "api::tag.tag", + randomTagId, // Use the tag's ID + { + filter: { + publishedAt: { + $notNull: true, + }, + }, + fields: ['id', 'name'] + } + ); + + selectedTags.push(await sanitize.contentAPI.output(rawTag, contentType, { auth: ctx.state.auth })); + + // Remove the selected tag ID from the array to avoid duplicates + tagIds.splice(randomIndex, 1); + } + + ctx.body = selectedTags; + }, + + + + async createTagRelation(ctx) { + + // we have this controller which associates a tag with a vod + // this exists so users can indirectly update vod records which they dont have permissions to update + // first we need to get the user's request. + // they are telling us a vod ID and a tag ID + // our job is to get a reference to the vod, and add the tag relation. + + if (!ctx.request.body.data) return ctx.badRequest('data was missing from body'); + if (!ctx.request.body.data.tag) return ctx.badRequest('tag was missing from data'); + if (!ctx.request.body.data.vod) return ctx.badRequest('vod was missing from data'); + + const { tag, vod: vodId } = ctx.request.body.data; + + + + await strapi.entityService.update('api::vod.vod', vodId, { + data: { + tags: { + connect: [tag] + } + } + }) + + return 'OK' + + }, +})); + diff --git a/packages/strapi/src/api/tag/routes/tag.js b/packages/strapi/src/api/tag/routes/tag.js new file mode 100644 index 0000000..db82f8f --- /dev/null +++ b/packages/strapi/src/api/tag/routes/tag.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * tag router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; +const defaultRouter = createCoreRouter('api::tag.tag'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "POST", + path: "/tag/tagRelation", + handler: "api::tag.tag.createTagRelation" + }, + { + method: 'GET', + path: '/tag/random', + handler: 'api::tag.tag.random' + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes) diff --git a/packages/strapi/src/api/tag/services/tag.js b/packages/strapi/src/api/tag/services/tag.js new file mode 100644 index 0000000..538bebc --- /dev/null +++ b/packages/strapi/src/api/tag/services/tag.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * tag service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::tag.tag', ({ strapi }) => ({ + async assertTag(tagName, userId) { + + if (!tagName) throw new Error('tagName was missing from request'); + if (!userId) throw new Error('userId was missing from request'); + + let tagEntry; + + // does the named tag already exist? + // tagEntry = await strapi.db.query('api::tag.tag') + // .findOne({ where: { name: tagName } }); + tagEntry = (await strapi.entityService.findMany('api::tag.tag', { + limit: 1, + filters: { + $and: [ + { + publishedAt: { $notNull: true }, + }, + { + name: tagName + } + ] + } + }))[0] + + if (!tagEntry) { + tagEntry = await strapi.entityService.create('api::tag.tag', { + data: { + name: tagName, + creator: userId, + publishedAt: new Date(), + } + }) + } + + return tagEntry; + }, +})); diff --git a/packages/strapi/src/api/timestamp/content-types/timestamp/schema.json b/packages/strapi/src/api/timestamp/content-types/timestamp/schema.json new file mode 100644 index 0000000..71b8ffb --- /dev/null +++ b/packages/strapi/src/api/timestamp/content-types/timestamp/schema.json @@ -0,0 +1,47 @@ +{ + "kind": "collectionType", + "collectionName": "timestamps", + "info": { + "singularName": "timestamp", + "pluralName": "timestamps", + "displayName": "Timestamp", + "description": "" + }, + "options": { + "draftAndPublish": false, + "populateCreatorFields": true + }, + "pluginOptions": {}, + "attributes": { + "time": { + "type": "integer", + "required": true + }, + "tag": { + "type": "relation", + "relation": "oneToOne", + "target": "api::tag.tag", + "required": true + }, + "vod": { + "type": "relation", + "relation": "manyToOne", + "target": "api::vod.vod", + "inversedBy": "timestamps" + }, + "creatorId": { + "type": "integer", + "required": true + }, + "upvoters": { + "type": "relation", + "relation": "oneToMany", + "target": "plugin::users-permissions.user" + }, + "downvoters": { + "type": "relation", + "relation": "oneToMany", + "target": "plugin::users-permissions.user" + } + } +} diff --git a/packages/strapi/src/api/timestamp/controllers/timestamp.js b/packages/strapi/src/api/timestamp/controllers/timestamp.js new file mode 100644 index 0000000..a131f44 --- /dev/null +++ b/packages/strapi/src/api/timestamp/controllers/timestamp.js @@ -0,0 +1,166 @@ +'use strict'; + +/** + * timestamp controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::timestamp.timestamp', ({ strapi }) => ({ + + + async find(ctx) { + const { data, meta } = await super.find(ctx); + + // Iterate over each timestamp in the data array + const timestampsWithVotes = await Promise.all(data.map(async (timestamp) => { + // Retrieve the upvoters count for the current timestamp + // Retrieve the downvoters count for the current timestamp + const entry = await strapi.db + .query('api::timestamp.timestamp') + .findOne({ + populate: ['upvoters', 'downvoters'], + where: { + id: timestamp.id + } + }); + + const upvotesCount = entry.upvoters.length + const downvotesCount = entry.downvoters.length + + // Create new properties "upvotes" and "downvotes" on the timestamp object + timestamp.attributes.upvotes = upvotesCount; + timestamp.attributes.downvotes = downvotesCount; + + return timestamp; + })); + + + return { data: timestampsWithVotes, meta }; + }, + + + async assert(ctx) { + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.body.data) return ctx.badRequest('data was missing from body'); + if (!ctx.request.body.data.tagId) return ctx.badRequest('tagId was missing from data'); + if (ctx.request.body.data.time === undefined || ctx.request.body.data.time === null || ctx.request.body.data.time < 0) return ctx.badRequest('time was missing from data'); + if (!ctx.request.body.data.vodId) return ctx.badRequest('vodId was missing from data'); + const { time, tagId, vodId } = ctx.request.body.data; + const timestamp = await strapi.service('api::timestamp.timestamp').assertTimestamp(userId, tagId, vodId, time); + return timestamp; + }, + + + // greets https://docs.strapi.io/dev-docs/backend-customization/controllers#extending-core-controllers + // greets https://docs.strapi.io/dev-docs/backend-customization/controllers#adding-a-new-controller + // Method 2: Wrapping a core action (leaves core logic in place) + async create(ctx) { + // add creatorId to the record + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.body.data) return ctx.badRequest('data was missing from body'); + if (!ctx.request.body.data.tag) return ctx.badRequest('tag was missing from data'); + const { time, tag } = ctx.request.body.data; + + ctx.request.body.data.creatorId = userId + + // does the timestamp already exist with same combination of time+tag? + const duplicate = await strapi.db.query('api::timestamp.timestamp') + .findOne({ where: { time, tag }}) + + if (!!duplicate) return ctx.badRequest('A duplicate timestamp already exists!'); + + // Calling the default core action + const res = await super.create(ctx); + + return res + }, + + + async vote(ctx) { + const userId = ctx?.state?.user?.id; + const { direction } = ctx.request.body.data; + if (!ctx.request.params.id) return ctx.badRequest('id was missing from params'); + const { id } = ctx.request.params; + // get the ts to be voted on + const ts = await strapi.entityService.findOne('api::timestamp.timestamp', id) + if (!ts) return ctx.badRequest('timestamp does not exist'); + const res = await strapi.entityService.update('api::timestamp.timestamp', id, { + data: { + upvoters: direction === 1 ? { connect: [userId] } : { disconnect: [userId] }, + downvoters: direction === 1 ? { disconnect: [userId] } : { connect: [userId] } + } + }); + return res; + }, + + + + async delete(ctx) { + const userId = ctx?.state?.user?.id; + const { id } = ctx.request.params; + + // get the ts to be deleted + const ts = await strapi.entityService.findOne('api::timestamp.timestamp', id) + if (!ts) return ctx.badRequest('Timestamp does not exist') + + + // Refuse to delete if not the tag creator + if (ts.creatorId !== userId) return ctx.forbidden('Only the timestamp creator can delete the timestamp.') + + + const res = await super.delete(ctx) + return res + + }, + + + async deleteMine (ctx) { + // // some custom logic here + // ctx.query = { ...ctx.query, local: 'en' } + + const userId = ctx?.state?.user?.id; + if (!userId) return ctx.badRequest("There was no user id in the request!"); + if (!ctx.request.params.id) return ctx.badRequest('id was missing from params'); + const { id } = ctx.request.params; + + // constraints + // only able to delete tagVodRelation if + // * creator + // * publishedAt isBefore(now-24h) + + + // get the tvr the user wants to delete + const timestampToDelete = await strapi.entityService.findOne('api::timestamp.timestamp', id, { + populate: { + tag: true, + vod: true + } + }) + + if (!timestampToDelete) return ctx.badRequest('Timestamp to be deleted does not exist.'); + + + if (timestampToDelete.creatorId !== userId) + ctx.forbidden('only the creator of the timestamp can delete it'); + + if ((new Date(timestampToDelete.createdAt).valueOf()+86400000) < new Date().valueOf()) + ctx.forbidden('cannot delete tags older than 24 hours') + + // Calling the default core action + const { data, meta } = await super.delete(ctx); + + // delete the related tag if it has no other vod + // @todo?? or maybe this is handled by lifecycle hook? + + // // some more custom logic + // meta.date = Date.now() + + return { data, meta }; + } + + +})) + diff --git a/packages/strapi/src/api/timestamp/routes/timestamp.js b/packages/strapi/src/api/timestamp/routes/timestamp.js new file mode 100644 index 0000000..1c8e7c6 --- /dev/null +++ b/packages/strapi/src/api/timestamp/routes/timestamp.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * timestamp router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + + +const defaultRouter = createCoreRouter('api::timestamp.timestamp'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + + + +const myExtraRoutes = [ + { + method: 'POST', + path: '/timestamps/assert', + handler: 'api::timestamp.timestamp.assert' + }, + { + method: "PUT", + path: "/timestamps/:id/vote", + handler: "api::timestamp.timestamp.vote" + }, + { + method: 'DELETE', + path: '/timestamps/:id', + handler: 'api::timestamp.timestamp.delete' + }, + { + method: 'DELETE', + path: '/timestamps/deleteMine/:id', + handler: 'api::timestamp.timestamp.deleteMine' + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); + + diff --git a/packages/strapi/src/api/timestamp/services/timestamp.js b/packages/strapi/src/api/timestamp/services/timestamp.js new file mode 100644 index 0000000..bfa84c2 --- /dev/null +++ b/packages/strapi/src/api/timestamp/services/timestamp.js @@ -0,0 +1,44 @@ +'use strict'; + +/** + * timestamp service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::timestamp.timestamp', ({ strapi }) => ({ + async assertTimestamp(userId, tagId, vodId, time) { + const existingTimestamp = await strapi.entityService.findMany('api::timestamp.timestamp', { + populate: ['vod', 'tag'], + filters: { + $and: [ + { + tag: { + id: tagId + } + }, + { + vod: { + id: vodId + } + }, + { + time: parseInt(time) + } + ] + }, + limit: 1 + }) + if (existingTimestamp.length > 0) return existingTimestamp[0]; + const newTimestamp = await strapi.entityService.create('api::timestamp.timestamp', { + data: { + tag: tagId, + vod: vodId, + creatorId: userId, + time: time, + } + }); + + return newTimestamp; + } +})); diff --git a/packages/strapi/src/api/toy/content-types/toy/schema.json b/packages/strapi/src/api/toy/content-types/toy/schema.json new file mode 100644 index 0000000..4e2baba --- /dev/null +++ b/packages/strapi/src/api/toy/content-types/toy/schema.json @@ -0,0 +1,51 @@ +{ + "kind": "collectionType", + "collectionName": "toys", + "info": { + "singularName": "toy", + "pluralName": "toys", + "displayName": "Toy", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "tags": { + "type": "relation", + "relation": "oneToMany", + "target": "api::tag.tag", + "mappedBy": "toy" + }, + "make": { + "type": "string", + "required": true + }, + "model": { + "type": "string", + "required": true + }, + "aspectRatio": { + "type": "string", + "default": "2:1", + "required": true + }, + "image2": { + "type": "string", + "default": "https://futureporn-b2.b-cdn.net/default-thumbnail.webp", + "required": true + }, + "linkTag": { + "type": "relation", + "relation": "oneToOne", + "target": "api::tag.tag" + }, + "vtubers": { + "type": "relation", + "relation": "oneToMany", + "target": "api::vtuber.vtuber", + "mappedBy": "toy" + } + } +} diff --git a/packages/strapi/src/api/toy/controllers/toy.js b/packages/strapi/src/api/toy/controllers/toy.js new file mode 100644 index 0000000..32bae69 --- /dev/null +++ b/packages/strapi/src/api/toy/controllers/toy.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * toy controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::toy.toy'); diff --git a/packages/strapi/src/api/toy/routes/toy.js b/packages/strapi/src/api/toy/routes/toy.js new file mode 100644 index 0000000..21d9d6a --- /dev/null +++ b/packages/strapi/src/api/toy/routes/toy.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * toy router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::toy.toy'); diff --git a/packages/strapi/src/api/toy/services/toy.js b/packages/strapi/src/api/toy/services/toy.js new file mode 100644 index 0000000..cd2dd6d --- /dev/null +++ b/packages/strapi/src/api/toy/services/toy.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * toy service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::toy.toy'); diff --git a/packages/strapi/src/api/tweet/content-types/tweet/lifecycles.js b/packages/strapi/src/api/tweet/content-types/tweet/lifecycles.js new file mode 100644 index 0000000..b92154a --- /dev/null +++ b/packages/strapi/src/api/tweet/content-types/tweet/lifecycles.js @@ -0,0 +1,136 @@ +const generateCuid = require('../../../../../misc/generateCuid.js'); + +const cbUrlRegex = /chaturbate\.com/i; +const fanslyUrlRegex = /https?:\/\/(?:www\.)?fans(?:\.ly|ly\.com)\/r\/[a-zA-Z0-9_]+/; + +const cbAlternativeUrls = [ + 'shorturl.at/tNUVY' // used by ProjektMelody in the early days +] + + +/** + * Returns true if the tweet contains a chaturbate.com link + * + * @param {Object} tweet + * @returns {Boolean} + */ +const containsCBInviteLink = (tweet) => { + const containsCbUrl = (link) => { + if (!link?.url) return false; + const isCbUrl = cbUrlRegex.test(link.url); + const isAlternativeCbUrl = cbAlternativeUrls.some(alternativeUrl => link.url.includes(alternativeUrl)); + return isCbUrl || isAlternativeCbUrl; + } + try { + if (!tweet?.links) return false; + return tweet.links.some(containsCbUrl) + } catch (e) { + logger.log({ level: 'error', message: 'ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR' }); + logger.log({ level: 'error', message: e }); + return false; + } +}; + +const containsFanslyInviteLink = (tweet) => { + const containsFanslyUrl = (link) => { + if (!link?.url) return false; + return (fanslyUrlRegex.test(link?.url)) + } + try { + if (!tweet?.links) return false; + return tweet.links.some(containsFanslyUrl) + } catch (e) { + logger.log({ level: 'error', message: 'ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR ERROR' }); + logger.log({ level: 'error', message: e }); + return false; + } +}; + + +const deriveTitle = (text) => { + // greetz https://www.urlregex.com/ + const urlRegex = /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/g; + let title = text + .replace(urlRegex, '') // remove urls + .replace(/\n/g, ' ') // replace newlines with spaces + .replace(/>/g, '>') // gimme dem greater-than brackets + .replace(/</g, '<') // i want them less-thans too + .replace(/&/g, '&') // ampersands are sexy + .replace(/\s+$/, ''); // remove trailing whitespace + return title; +}; + + + + +module.exports = { + async afterCreate(event) { + // * [ ] Create Stream + const id = event.result.id; + console.log(`>>> tweet afterCreate id=${id}`); + const { data } = event.params; + + console.log(data); + + // IF this tweet was a fansly or chaturbate invite, create & associate Stream + if (data.isChaturbateInvite || data.isFanslyInvite) { + const stream = await strapi.entityService.create('api::stream.stream', { + data: { + tweet: id, + vtuber: data.vtuber, + date: data.date, + date_str: data.date, + date2: data.date, + archiveStatus: 'missing', + cuid: generateCuid() + } + }); + + // console.log(data) + console.log(`stream.id=${stream.id}`); + + // const existingData = await strapi.entityService.findOne("api::stream.stream", id, { + // populate: ['vods'] + // }) + } + }, + async beforeCreate(event) { + // * [x] Set platform to CB or Fansly + // * [x] Set vtuber + // * [x] Set date + // * [x] Set id_str + // * [x] Set url + + const { data, where, select, populate } = event.params; + console.log('>>> tweet beforeCreate!'); + + const tweet = JSON.parse(data.json); + // console.log(tweet); + console.log(`containsCBInviteLink=${containsCBInviteLink(tweet)}, containsFanslyInviteLink=${containsFanslyInviteLink(tweet)}`); + + + data.isChaturbateInvite = containsCBInviteLink(tweet); + data.isFanslyInvite = containsFanslyInviteLink(tweet); + + const tweetDate = new Date(tweet.date).toISOString(); + data.id_str = tweet.id_str; + data.date = tweetDate; + data.date2 = tweetDate; + data.url = tweet.url; + + // Set VTuber + const twitterUsername = tweet.user.username; + const vtuberRecords = await strapi.entityService.findMany("api::vtuber.vtuber", { + fields: ['displayName', 'slug', 'id'], + filters: { + twitter: { + $endsWithi: twitterUsername + } + } + }); + if (!!vtuberRecords[0]) data.vtuber = vtuberRecords[0].id; + + + + } +} \ No newline at end of file diff --git a/packages/strapi/src/api/tweet/content-types/tweet/schema.json b/packages/strapi/src/api/tweet/content-types/tweet/schema.json new file mode 100644 index 0000000..3d94b7c --- /dev/null +++ b/packages/strapi/src/api/tweet/content-types/tweet/schema.json @@ -0,0 +1,49 @@ +{ + "kind": "collectionType", + "collectionName": "tweets", + "info": { + "singularName": "tweet", + "pluralName": "tweets", + "displayName": "Tweet", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "id_str": { + "type": "string", + "required": false, + "unique": true + }, + "url": { + "type": "string", + "required": false + }, + "date2": { + "type": "string", + "required": false, + "unique": true + }, + "json": { + "type": "text", + "required": true + }, + "vtuber": { + "type": "relation", + "relation": "oneToOne", + "target": "api::vtuber.vtuber" + }, + "isChaturbateInvite": { + "type": "boolean" + }, + "isFanslyInvite": { + "type": "boolean" + }, + "date": { + "type": "datetime", + "unique": true + } + } +} diff --git a/packages/strapi/src/api/tweet/controllers/tweet.js b/packages/strapi/src/api/tweet/controllers/tweet.js new file mode 100644 index 0000000..bbb1fed --- /dev/null +++ b/packages/strapi/src/api/tweet/controllers/tweet.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * tweet controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::tweet.tweet'); diff --git a/packages/strapi/src/api/tweet/routes/tweet.js b/packages/strapi/src/api/tweet/routes/tweet.js new file mode 100644 index 0000000..efce886 --- /dev/null +++ b/packages/strapi/src/api/tweet/routes/tweet.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * tweet router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::tweet.tweet'); diff --git a/packages/strapi/src/api/tweet/services/tweet.js b/packages/strapi/src/api/tweet/services/tweet.js new file mode 100644 index 0000000..9be1baf --- /dev/null +++ b/packages/strapi/src/api/tweet/services/tweet.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * tweet service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::tweet.tweet'); diff --git a/packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/lifecycles.js b/packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/lifecycles.js new file mode 100644 index 0000000..b9bab96 --- /dev/null +++ b/packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/lifecycles.js @@ -0,0 +1,58 @@ +const { S3Client, DeleteObjectCommand } = require("@aws-sdk/client-s3"); + +if (!process.env.S3_USC_BUCKET_NAME) throw new Error('S3_USC_BUCKET_NAME must be defined in env'); +if (!process.env.S3_USC_BUCKET_ENDPOINT) throw new Error('S3_USC_BUCKET_ENDPOINT must be defined in env'); +if (!process.env.S3_USC_BUCKET_REGION) throw new Error('S3_USC_BUCKET_REGION must be defined in env'); +if (!process.env.AWS_ACCESS_KEY_ID) throw new Error('AWS_ACCESS_KEY_ID must be defined in env'); +if (!process.env.AWS_SECRET_ACCESS_KEY) throw new Error('AWS_SECRET_ACCESS_KEY must be defined in env'); + +// AWS.config.loadFromPath('./credentials-ehl.json'); + + + +module.exports = { + async beforeCreate(event) { + console.log('>>> beforeCreate!'); + + }, + + // when strapi deletes a USC, we delete the related files in the S3 bucket. + async afterDelete(event) { + + console.log('>>> afterDelete'); + console.log(event); + + const { result } = event; + + + + // a client can be shared by different commands. + const client = new S3Client({ + endpoint: process.env.S3_USC_BUCKET_ENDPOINT, + region: process.env.S3_USC_BUCKET_REGION + }); + // https://fp-usc-dev.s3.us-west-000.backblazeb2.com/GEB7_QcaUAAQ29O.jpg + + const res = await client.send(new DeleteObjectCommand({ + Bucket: process.env.S3_USC_BUCKET_NAME, + Key: result.key + })); + + console.log(res); + + + + // var s3 = new S3(); + // var params = { Bucket: process.env.S3_USC_BUCKET, Key: 'your object' }; + + // const res = await s3.deleteObject(params).promise(); + + // console.log('deletion complete.'); + // console.log(res); + + // , function(err, data) { + // if (err) console.log(err, err.stack); // error + // else console.log(); // deleted + // }); + } +} \ No newline at end of file diff --git a/packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/schema.json b/packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/schema.json new file mode 100644 index 0000000..dc1eb4a --- /dev/null +++ b/packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/schema.json @@ -0,0 +1,36 @@ +{ + "kind": "collectionType", + "collectionName": "user_submitted_contents", + "info": { + "singularName": "user-submitted-content", + "pluralName": "user-submitted-contents", + "displayName": "User Submitted Content", + "description": "" + }, + "options": { + "draftAndPublish": false + }, + "pluginOptions": {}, + "attributes": { + "uploader": { + "type": "relation", + "relation": "oneToOne", + "target": "plugin::users-permissions.user" + }, + "attribution": { + "type": "boolean", + "default": false + }, + "date": { + "type": "string", + "required": true + }, + "notes": { + "type": "richtext" + }, + "files": { + "type": "json", + "required": true + } + } +} diff --git a/packages/strapi/src/api/user-submitted-content/controllers/user-submitted-content.js b/packages/strapi/src/api/user-submitted-content/controllers/user-submitted-content.js new file mode 100644 index 0000000..a94c328 --- /dev/null +++ b/packages/strapi/src/api/user-submitted-content/controllers/user-submitted-content.js @@ -0,0 +1,60 @@ + +'use strict'; + + +if (!process.env.CDN_BUCKET_USC_URL) throw new Error('CDN_BUCKET_USC_URL environment variable is required!'); + +/** + * user-submitted-content controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +// greets https://docs.strapi.io/dev-docs/backend-customization/controllers#adding-a-new-controller +module.exports = createCoreController('api::user-submitted-content.user-submitted-content', ({ strapi }) => ({ + + async createFromUppy(ctx) { + try { + // Destructure data from the request body + const { data } = ctx.request.body; + + console.log(data); + + // Check for required fields in the data + const requiredFields = ['files', 'vtuber', 'date', 'notes', 'attribution']; + if (!data) { + return ctx.badRequest('ctx.request.body.data was missing.'); + } + for (const field of requiredFields) { + console.log(`checking field=${field} data[field]=${data[field]}`); + if (data[field] === undefined || data[field] === null) { + return ctx.badRequest(`${field} was missing from request data.`); + } + } + + // Extract relevant data + const { files, vtuber, date, notes, attribution } = data; + const uploader = ctx.state.user.id; + + console.log('Creating user-submitted content'); + const usc = await strapi.entityService.create('api::user-submitted-content.user-submitted-content', { + data: { + uploader, + files: files.map((f) => ({ ...f, cdnUrl: `${process.env.CDN_BUCKET_USC_URL}/${f.key}` })), + vtuber, + date, + notes, + attribution, + } + }); + + return usc; + } catch (error) { + // Handle unexpected errors + console.error(error); + return ctx.badRequest('An error occurred while processing the request'); + } + } + + })); + \ No newline at end of file diff --git a/packages/strapi/src/api/user-submitted-content/routes/user-submitted-content.js b/packages/strapi/src/api/user-submitted-content/routes/user-submitted-content.js new file mode 100644 index 0000000..ed91c49 --- /dev/null +++ b/packages/strapi/src/api/user-submitted-content/routes/user-submitted-content.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * user-submitted-content router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +const defaultRouter = createCoreRouter('api::user-submitted-content.user-submitted-content'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "POST", + path: "/user-submitted-contents/createFromUppy", + handler: "api::user-submitted-content.user-submitted-content.createFromUppy" + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); \ No newline at end of file diff --git a/packages/strapi/src/api/user-submitted-content/services/user-submitted-content.js b/packages/strapi/src/api/user-submitted-content/services/user-submitted-content.js new file mode 100644 index 0000000..9c176aa --- /dev/null +++ b/packages/strapi/src/api/user-submitted-content/services/user-submitted-content.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * user-submitted-content service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::user-submitted-content.user-submitted-content'); diff --git a/packages/strapi/src/api/vod/content-types/lifecycles.js b/packages/strapi/src/api/vod/content-types/lifecycles.js new file mode 100644 index 0000000..5090507 --- /dev/null +++ b/packages/strapi/src/api/vod/content-types/lifecycles.js @@ -0,0 +1,12 @@ +const { init } = require('@paralleldrive/cuid2'); + +module.exports = { + async beforeUpdate(event) { + const { data } = event.params; + if (!data.cuid) { + const length = 10; // 50% odds of collision after ~51,386,368 ids + const cuid = init({ length }); + event.params.data.cuid = cuid(); + } + } +} \ No newline at end of file diff --git a/packages/strapi/src/api/vod/content-types/vod/schema.json b/packages/strapi/src/api/vod/content-types/vod/schema.json new file mode 100644 index 0000000..2446a76 --- /dev/null +++ b/packages/strapi/src/api/vod/content-types/vod/schema.json @@ -0,0 +1,142 @@ +{ + "kind": "collectionType", + "collectionName": "vods", + "info": { + "singularName": "vod", + "pluralName": "vods", + "displayName": "VOD", + "description": "" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": {}, + "attributes": { + "videoSrcHash": { + "type": "string", + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}", + "required": false, + "unique": true + }, + "video720Hash": { + "type": "string", + "unique": true, + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}" + }, + "video480Hash": { + "type": "string", + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}", + "unique": true + }, + "video360Hash": { + "type": "string", + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}", + "unique": true + }, + "video240Hash": { + "type": "string", + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}", + "unique": true + }, + "thinHash": { + "type": "string", + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}", + "unique": true + }, + "thiccHash": { + "type": "string", + "regex": "Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}", + "unique": true + }, + "announceTitle": { + "type": "string" + }, + "announceUrl": { + "type": "string", + "unique": false + }, + "note": { + "type": "text" + }, + "date": { + "type": "datetime" + }, + "date2": { + "type": "string", + "required": true + }, + "spoilers": { + "type": "richtext" + }, + "title": { + "type": "string" + }, + "uploader": { + "type": "relation", + "relation": "oneToOne", + "target": "plugin::users-permissions.user" + }, + "muxAsset": { + "type": "relation", + "relation": "oneToOne", + "target": "api::mux-asset.mux-asset" + }, + "videoSrcB2": { + "type": "relation", + "relation": "oneToOne", + "target": "api::b2-file.b2-file" + }, + "thumbnail": { + "type": "relation", + "relation": "oneToOne", + "target": "api::b2-file.b2-file" + }, + "chatLog": { + "type": "richtext" + }, + "tags": { + "type": "relation", + "relation": "manyToMany", + "target": "api::tag.tag", + "mappedBy": "vods" + }, + "timestamps": { + "type": "relation", + "relation": "oneToMany", + "target": "api::timestamp.timestamp", + "mappedBy": "vod" + }, + "tagVodRelations": { + "type": "relation", + "relation": "oneToMany", + "target": "api::tag-vod-relation.tag-vod-relation", + "mappedBy": "vod" + }, + "vtuber": { + "type": "relation", + "relation": "manyToOne", + "target": "api::vtuber.vtuber", + "inversedBy": "vods" + }, + "stream": { + "type": "relation", + "relation": "manyToOne", + "target": "api::stream.stream", + "inversedBy": "vods" + }, + "archiveStatus": { + "type": "enumeration", + "enum": [ + "missing", + "issue", + "good" + ], + "required": false, + "default": "issue" + }, + "cuid": { + "type": "string", + "unique": true + } + } +} diff --git a/packages/strapi/src/api/vod/controllers/vod.js b/packages/strapi/src/api/vod/controllers/vod.js new file mode 100644 index 0000000..9dc9ff2 --- /dev/null +++ b/packages/strapi/src/api/vod/controllers/vod.js @@ -0,0 +1,85 @@ +'use strict'; + + +const { sanitize } = require('@strapi/utils'); + +if (!process.env.CDN_BUCKET_USC_URL) throw new Error('CDN_BUCKET_USC_URL environment variable is required!'); + +/** + * vod controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +// greets https://docs.strapi.io/dev-docs/backend-customization/controllers#adding-a-new-controller +module.exports = createCoreController('api::vod.vod', ({ strapi }) => ({ + + async createFromUppy(ctx) { + + const uploaderId = ctx.state.user.id; + + if (!ctx.request.body.data) return ctx.badRequest("data was missing in request body"); + if (!ctx.request.body.data.date) return ctx.badRequest("date was missing"); + if (!ctx.request.body.data.b2Key) return ctx.badRequest("b2Key was missing"); + if (!ctx.request.body.data.b2UploadId) return ctx.badRequest("b2UploadId was missing"); + + + const videoSrcB2 = await strapi.entityService.create('api::b2-file.b2-file', { + data: { + url: `https://f000.backblazeb2.com/b2api/v1/b2_download_file_by_id?fileId=${ctx.request.body.data.b2UploadId}`, + key: ctx.request.body.data.b2Key, + uploadId: ctx.request.body.data.b2UploadId, + cdnUrl: `${process.env.CDN_BUCKET_USC_URL}/${ctx.request.body.data.b2Key}` + } + }); + + const vod = await strapi.entityService.create('api::vod.vod', { + data: { + notes: ctx.request.body.data.notes, + date: ctx.request.body.data.date, + videoSrcB2: videoSrcB2.id, + publishedAt: null, + uploader: uploaderId, + } + }); + + return vod; + }, + + // greets https://stackoverflow.com/a/73929966/1004931 + async random(ctx) { + const numberOfEntries = 1; + const contentType = strapi.contentType('api::vod.vod') + + // Fetch only the 'id' field of all VODs + const entries = await strapi.entityService.findMany( + "api::vod.vod", + { + fields: ['id'], + filters: { + publishedAt: { + $notNull: true, + }, + } + } + ); + + // Randomly select one entry + const randomEntry = entries[Math.floor(Math.random() * entries.length)]; + + // Fetch the full details of the randomly selected VOD + const rawVod = await strapi.entityService.findOne( + "api::vod.vod", + randomEntry.id, + { + populate: '*', + }, + ); + + const sanitizedOutput = await sanitize.contentAPI.output(rawVod, contentType, { auth: ctx.state.auth }); + + ctx.body = sanitizedOutput; + } + + }) +) \ No newline at end of file diff --git a/packages/strapi/src/api/vod/routes/vod.js b/packages/strapi/src/api/vod/routes/vod.js new file mode 100644 index 0000000..132dacf --- /dev/null +++ b/packages/strapi/src/api/vod/routes/vod.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * vod router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +const defaultRouter = createCoreRouter('api::vod.vod'); + +// greets https://forum.strapi.io/t/how-to-add-custom-routes-to-core-routes-in-strapi-4/14070/7 +const customRouter = (innerRouter, extraRoutes = []) => { + let routes; + return { + get prefix() { + return innerRouter.prefix; + }, + get routes() { + if (!routes) routes = extraRoutes.concat(innerRouter.routes) + return routes; + }, + }; +}; + +const myExtraRoutes = [ + { + method: "POST", + path: "/vods/createFromUppy", + handler: "api::vod.vod.createFromUppy" + }, + { + method: "GET", + path: "/vods/random", + handler: "api::vod.vod.random" + } +]; + +module.exports = customRouter(defaultRouter, myExtraRoutes); \ No newline at end of file diff --git a/packages/strapi/src/api/vod/services/vod.js b/packages/strapi/src/api/vod/services/vod.js new file mode 100644 index 0000000..56fcec1 --- /dev/null +++ b/packages/strapi/src/api/vod/services/vod.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * vod service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::vod.vod'); diff --git a/packages/strapi/src/api/vtuber/content-types/vtuber/lifecycles.js b/packages/strapi/src/api/vtuber/content-types/vtuber/lifecycles.js new file mode 100644 index 0000000..2be365a --- /dev/null +++ b/packages/strapi/src/api/vtuber/content-types/vtuber/lifecycles.js @@ -0,0 +1,23 @@ +const { createCanvas } = require('canvas'); + +function hexColorToBase64Image(hexColor) { + const canvas = createCanvas(1, 1); // Create a canvas + const ctx = canvas.getContext('2d'); + // Draw a rectangle filled with the hex color + ctx.fillStyle = hexColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + // Convert canvas content to base64 encoded image + const base64Image = canvas.toDataURL('image/png'); + return base64Image; +} + + + +module.exports = { + beforeUpdate(event) { + const { data, where, select, populate } = event.params; + const themeColor = event.params.data.themeColor; + const imageBlur = hexColorToBase64Image(themeColor); + event.params.data.imageBlur = imageBlur; + } +}; \ No newline at end of file diff --git a/packages/strapi/src/api/vtuber/content-types/vtuber/schema.json b/packages/strapi/src/api/vtuber/content-types/vtuber/schema.json new file mode 100644 index 0000000..91568cc --- /dev/null +++ b/packages/strapi/src/api/vtuber/content-types/vtuber/schema.json @@ -0,0 +1,118 @@ +{ + "kind": "collectionType", + "collectionName": "vtubers", + "info": { + "singularName": "vtuber", + "pluralName": "vtubers", + "displayName": "Vtuber", + "description": "" + }, + "options": { + "draftAndPublish": true + }, + "pluginOptions": {}, + "attributes": { + "displayName": { + "type": "string", + "required": true + }, + "chaturbate": { + "type": "string" + }, + "twitter": { + "type": "string" + }, + "patreon": { + "type": "string" + }, + "twitch": { + "type": "string" + }, + "tiktok": { + "type": "string" + }, + "onlyfans": { + "type": "string" + }, + "youtube": { + "type": "string" + }, + "linktree": { + "type": "string" + }, + "carrd": { + "type": "string" + }, + "fansly": { + "type": "string" + }, + "pornhub": { + "type": "string" + }, + "discord": { + "type": "string" + }, + "reddit": { + "type": "string" + }, + "throne": { + "type": "string" + }, + "instagram": { + "type": "string" + }, + "facebook": { + "type": "string" + }, + "merch": { + "type": "string" + }, + "slug": { + "type": "string", + "required": true + }, + "vods": { + "type": "relation", + "relation": "oneToMany", + "target": "api::vod.vod", + "mappedBy": "vtuber" + }, + "description1": { + "type": "text", + "required": true + }, + "description2": { + "type": "text" + }, + "image": { + "type": "string", + "required": true + }, + "themeColor": { + "type": "string", + "default": "#353FFF", + "required": true + }, + "imageBlur": { + "type": "string", + "default": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABmJLR0QA/wD/AP+gvaeTAAAADUlEQVQImWMwtf//HwAEkwJzh0T9qwAAAABJRU5ErkJggg==" + }, + "toys": { + "type": "relation", + "relation": "oneToMany", + "target": "api::toy.toy" + }, + "toy": { + "type": "relation", + "relation": "manyToOne", + "target": "api::toy.toy", + "inversedBy": "vtubers" + }, + "streams": { + "type": "relation", + "relation": "oneToMany", + "target": "api::stream.stream", + "mappedBy": "vtuber" + } + } +} diff --git a/packages/strapi/src/api/vtuber/controllers/vtuber.js b/packages/strapi/src/api/vtuber/controllers/vtuber.js new file mode 100644 index 0000000..e13bb5e --- /dev/null +++ b/packages/strapi/src/api/vtuber/controllers/vtuber.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * vtuber controller + */ + +const { createCoreController } = require('@strapi/strapi').factories; + +module.exports = createCoreController('api::vtuber.vtuber'); diff --git a/packages/strapi/src/api/vtuber/routes/vtuber.js b/packages/strapi/src/api/vtuber/routes/vtuber.js new file mode 100644 index 0000000..8936096 --- /dev/null +++ b/packages/strapi/src/api/vtuber/routes/vtuber.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * vtuber router + */ + +const { createCoreRouter } = require('@strapi/strapi').factories; + +module.exports = createCoreRouter('api::vtuber.vtuber'); diff --git a/packages/strapi/src/api/vtuber/services/vtuber.js b/packages/strapi/src/api/vtuber/services/vtuber.js new file mode 100644 index 0000000..c792042 --- /dev/null +++ b/packages/strapi/src/api/vtuber/services/vtuber.js @@ -0,0 +1,9 @@ +'use strict'; + +/** + * vtuber service + */ + +const { createCoreService } = require('@strapi/strapi').factories; + +module.exports = createCoreService('api::vtuber.vtuber'); diff --git a/packages/strapi/src/extensions/.gitkeep b/packages/strapi/src/extensions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/strapi/src/extensions/users-permissions/.eslintignore b/packages/strapi/src/extensions/users-permissions/.eslintignore new file mode 100644 index 0000000..1723d82 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +.eslintrc.js diff --git a/packages/strapi/src/extensions/users-permissions/.eslintrc.js b/packages/strapi/src/extensions/users-permissions/.eslintrc.js new file mode 100644 index 0000000..a6c2c1e --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/.eslintrc.js @@ -0,0 +1,14 @@ +module.exports = { + root: true, + overrides: [ + { + files: ['admin/**/*'], + extends: ['custom/front'], + }, + { + files: ['**/*'], + excludedFiles: ['admin/**/*'], + extends: ['custom/back'], + }, + ], +}; diff --git a/packages/strapi/src/extensions/users-permissions/LICENSE b/packages/strapi/src/extensions/users-permissions/LICENSE new file mode 100644 index 0000000..638baf8 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2015-present Strapi Solutions SAS + +Portions of the Strapi software are licensed as follows: + +* All software that resides under an "ee/" directory (the “EE Software”), if that directory exists, is licensed under the license defined in "ee/LICENSE". + +* All software outside of the above-mentioned directories or restrictions above is available under the "MIT Expat" license as set forth below. + +MIT Expat License + +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. \ No newline at end of file diff --git a/packages/strapi/src/extensions/users-permissions/README.md b/packages/strapi/src/extensions/users-permissions/README.md new file mode 100644 index 0000000..af1f65a --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/README.md @@ -0,0 +1 @@ +# Strapi plugin diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/getMethodColor.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/getMethodColor.js new file mode 100644 index 0000000..1ad903b --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/getMethodColor.js @@ -0,0 +1,41 @@ +const getMethodColor = (verb) => { + switch (verb) { + case 'POST': { + return { + text: 'success600', + border: 'success200', + background: 'success100', + }; + } + case 'GET': { + return { + text: 'secondary600', + border: 'secondary200', + background: 'secondary100', + }; + } + case 'PUT': { + return { + text: 'warning600', + border: 'warning200', + background: 'warning100', + }; + } + case 'DELETE': { + return { + text: 'danger600', + border: 'danger200', + background: 'danger100', + }; + } + default: { + return { + text: 'neutral600', + border: 'neutral200', + background: 'neutral100', + }; + } + } +}; + +export default getMethodColor; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/index.js new file mode 100644 index 0000000..7680ff0 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/index.js @@ -0,0 +1,72 @@ +import React from 'react'; + +import { Box, Flex, Typography } from '@strapi/design-system'; +import map from 'lodash/map'; +import tail from 'lodash/tail'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import styled from 'styled-components'; + +import getMethodColor from './getMethodColor'; + +const MethodBox = styled(Box)` + margin: -1px; + border-radius: ${({ theme }) => theme.spaces[1]} 0 0 ${({ theme }) => theme.spaces[1]}; +`; + +function BoundRoute({ route }) { + const { formatMessage } = useIntl(); + + const { method, handler: title, path } = route; + const formattedRoute = path ? tail(path.split('/')) : []; + const [controller = '', action = ''] = title ? title.split('.') : []; + const colors = getMethodColor(route.method); + + return ( + + + {formatMessage({ + id: 'users-permissions.BoundRoute.title', + defaultMessage: 'Bound route to', + })} +   + {controller} + + .{action} + + + + + + {method} + + + + {map(formattedRoute, (value) => ( + + /{value} + + ))} + + + + ); +} + +BoundRoute.defaultProps = { + route: { + handler: 'Nocontroller.error', + method: 'GET', + path: '/there-is-no-path', + }, +}; + +BoundRoute.propTypes = { + route: PropTypes.shape({ + handler: PropTypes.string, + method: PropTypes.string, + path: PropTypes.string, + }), +}; + +export default BoundRoute; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/Input/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/Input/index.js new file mode 100644 index 0000000..e5eaf94 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/Input/index.js @@ -0,0 +1,123 @@ +/** + * + * Input + * + */ + +import React from 'react'; + +import { TextInput, ToggleInput } from '@strapi/design-system'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +const Input = ({ + description, + disabled, + intlLabel, + error, + name, + onChange, + placeholder, + providerToEditName, + type, + value, +}) => { + const { formatMessage } = useIntl(); + const inputValue = + name === 'noName' + ? `${window.strapi.backendURL}/api/connect/${providerToEditName}/callback` + : value; + + const label = formatMessage( + { id: intlLabel.id, defaultMessage: intlLabel.defaultMessage }, + { provider: providerToEditName, ...intlLabel.values } + ); + const hint = description + ? formatMessage( + { id: description.id, defaultMessage: description.defaultMessage }, + { provider: providerToEditName, ...description.values } + ) + : ''; + + if (type === 'bool') { + return ( + { + onChange({ target: { name, value: e.target.checked } }); + }} + /> + ); + } + + const formattedPlaceholder = placeholder + ? formatMessage( + { id: placeholder.id, defaultMessage: placeholder.defaultMessage }, + { ...placeholder.values } + ) + : ''; + + const errorMessage = error ? formatMessage({ id: error, defaultMessage: error }) : ''; + + return ( + + ); +}; + +Input.defaultProps = { + description: null, + disabled: false, + error: '', + placeholder: null, + value: '', +}; + +Input.propTypes = { + description: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + values: PropTypes.object, + }), + disabled: PropTypes.bool, + error: PropTypes.string, + intlLabel: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + values: PropTypes.object, + }).isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + placeholder: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + values: PropTypes.object, + }), + providerToEditName: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), +}; + +export default Input; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/index.js new file mode 100644 index 0000000..83c0592 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/index.js @@ -0,0 +1,126 @@ +/** + * + * FormModal + * + */ + +import React from 'react'; + +import { + Button, + Flex, + Grid, + GridItem, + ModalBody, + ModalFooter, + ModalHeader, + ModalLayout, +} from '@strapi/design-system'; +import { Breadcrumbs, Crumb } from '@strapi/design-system/v2'; +import { Form } from '@strapi/helper-plugin'; +import { Formik } from 'formik'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import Input from './Input'; + +const FormModal = ({ + headerBreadcrumbs, + initialData, + isSubmiting, + layout, + isOpen, + onSubmit, + onToggle, + providerToEditName, +}) => { + const { formatMessage } = useIntl(); + + if (!isOpen) { + return null; + } + + return ( + + + + {headerBreadcrumbs.map((crumb, index, arr) => ( + + {crumb} + + ))} + + + onSubmit(values)} + initialValues={initialData} + validationSchema={layout.schema} + validateOnChange={false} + > + {({ errors, handleChange, values }) => { + return ( +
+ + + + {layout.form.map((row) => { + return row.map((input) => { + return ( + + + + ); + }); + })} + + + + + {formatMessage({ + id: 'app.components.Button.cancel', + defaultMessage: 'Cancel', + })} + + } + endActions={ + + } + /> + + ); + }} +
+
+ ); +}; + +FormModal.defaultProps = { + initialData: null, + providerToEditName: null, +}; + +FormModal.propTypes = { + headerBreadcrumbs: PropTypes.arrayOf(PropTypes.string).isRequired, + initialData: PropTypes.object, + layout: PropTypes.shape({ + form: PropTypes.arrayOf(PropTypes.array), + schema: PropTypes.object, + }).isRequired, + isOpen: PropTypes.bool.isRequired, + isSubmiting: PropTypes.bool.isRequired, + onSubmit: PropTypes.func.isRequired, + onToggle: PropTypes.func.isRequired, + providerToEditName: PropTypes.string, +}; + +export default FormModal; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/CheckboxWrapper.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/CheckboxWrapper.js new file mode 100644 index 0000000..e3165e2 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/CheckboxWrapper.js @@ -0,0 +1,30 @@ +import { Box } from '@strapi/design-system'; +import styled, { css } from 'styled-components'; + +const activeCheckboxWrapperStyles = css` + background: ${(props) => props.theme.colors.primary100}; + svg { + opacity: 1; + } +`; + +const CheckboxWrapper = styled(Box)` + display: flex; + justify-content: space-between; + align-items: center; + + svg { + opacity: 0; + path { + fill: ${(props) => props.theme.colors.primary600}; + } + } + + /* Show active style both on hover and when the action is selected */ + ${(props) => props.isActive && activeCheckboxWrapperStyles} + &:hover { + ${activeCheckboxWrapperStyles} + } +`; + +export default CheckboxWrapper; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/SubCategory.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/SubCategory.js new file mode 100644 index 0000000..a9a91bd --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/SubCategory.js @@ -0,0 +1,131 @@ +import React, { useCallback, useMemo } from 'react'; + +import { + Box, + Checkbox, + Flex, + Typography, + Grid, + GridItem, + VisuallyHidden, +} from '@strapi/design-system'; +import { Cog as CogIcon } from '@strapi/icons'; +import get from 'lodash/get'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import styled from 'styled-components'; + +import { useUsersPermissions } from '../../../contexts/UsersPermissionsContext'; + +import CheckboxWrapper from './CheckboxWrapper'; + +const Border = styled.div` + flex: 1; + align-self: center; + border-top: 1px solid ${({ theme }) => theme.colors.neutral150}; +`; + +const SubCategory = ({ subCategory }) => { + const { formatMessage } = useIntl(); + const { onChange, onChangeSelectAll, onSelectedAction, selectedAction, modifiedData } = + useUsersPermissions(); + + const currentScopedModifiedData = useMemo(() => { + return get(modifiedData, subCategory.name, {}); + }, [modifiedData, subCategory]); + + const hasAllActionsSelected = useMemo(() => { + return Object.values(currentScopedModifiedData).every((action) => action.enabled === true); + }, [currentScopedModifiedData]); + + const hasSomeActionsSelected = useMemo(() => { + return ( + Object.values(currentScopedModifiedData).some((action) => action.enabled === true) && + !hasAllActionsSelected + ); + }, [currentScopedModifiedData, hasAllActionsSelected]); + + const handleChangeSelectAll = useCallback( + ({ target: { name } }) => { + onChangeSelectAll({ target: { name, value: !hasAllActionsSelected } }); + }, + [hasAllActionsSelected, onChangeSelectAll] + ); + + const isActionSelected = useCallback( + (actionName) => { + return selectedAction === actionName; + }, + [selectedAction] + ); + + return ( + + + + + {subCategory.label} + + + + + + handleChangeSelectAll({ target: { name: subCategory.name, value } }) + } + indeterminate={hasSomeActionsSelected} + > + {formatMessage({ id: 'app.utils.select-all', defaultMessage: 'Select all' })} + + + + + + {subCategory.actions.map((action) => { + const name = `${action.name}.enabled`; + + return ( + + + onChange({ target: { name, value } })} + > + {action.label} + + + + + ); + })} + + + + ); +}; + +SubCategory.propTypes = { + subCategory: PropTypes.object.isRequired, +}; + +export default SubCategory; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/index.js new file mode 100644 index 0000000..08ff3ff --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/index.js @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; + +import { Box } from '@strapi/design-system'; +import sortBy from 'lodash/sortBy'; +import PropTypes from 'prop-types'; + +import SubCategory from './SubCategory'; + +const PermissionRow = ({ name, permissions }) => { + const subCategories = useMemo(() => { + return sortBy( + Object.values(permissions.controllers).reduce((acc, curr, index) => { + const currentName = `${name}.controllers.${Object.keys(permissions.controllers)[index]}`; + const actions = sortBy( + Object.keys(curr).reduce((acc, current) => { + return [ + ...acc, + { + ...curr[current], + label: current, + name: `${currentName}.${current}`, + }, + ]; + }, []), + 'label' + ); + + return [ + ...acc, + { + actions, + label: Object.keys(permissions.controllers)[index], + name: currentName, + }, + ]; + }, []), + 'label' + ); + }, [name, permissions]); + + return ( + + {subCategories.map((subCategory) => ( + + ))} + + ); +}; + +PermissionRow.propTypes = { + name: PropTypes.string.isRequired, + permissions: PropTypes.object.isRequired, +}; + +export default PermissionRow; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/index.js new file mode 100644 index 0000000..260ae00 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/index.js @@ -0,0 +1,57 @@ +import React, { useReducer } from 'react'; + +import { Accordion, AccordionContent, AccordionToggle, Box, Flex } from '@strapi/design-system'; +import { useIntl } from 'react-intl'; + +import { useUsersPermissions } from '../../contexts/UsersPermissionsContext'; +import formatPluginName from '../../utils/formatPluginName'; + +import init from './init'; +import PermissionRow from './PermissionRow'; +import { initialState, reducer } from './reducer'; + +const Permissions = () => { + const { modifiedData } = useUsersPermissions(); + const { formatMessage } = useIntl(); + const [{ collapses }, dispatch] = useReducer(reducer, initialState, (state) => + init(state, modifiedData) + ); + + const handleToggle = (index) => + dispatch({ + type: 'TOGGLE_COLLAPSE', + index, + }); + + return ( + + {collapses.map((collapse, index) => ( + handleToggle(index)} + key={collapse.name} + variant={index % 2 === 0 ? 'secondary' : undefined} + > + + + + + + + + ))} + + ); +}; + +export default Permissions; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/init.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/init.js new file mode 100644 index 0000000..4125920 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/init.js @@ -0,0 +1,9 @@ +const init = (initialState, permissions) => { + const collapses = Object.keys(permissions) + .sort() + .map((name) => ({ name, isOpen: false })); + + return { ...initialState, collapses }; +}; + +export default init; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/reducer.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/reducer.js new file mode 100644 index 0000000..705e580 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/reducer.js @@ -0,0 +1,27 @@ +import produce from 'immer'; + +const initialState = { + collapses: [], +}; + +const reducer = (state, action) => + // eslint-disable-next-line consistent-return + produce(state, (draftState) => { + switch (action.type) { + case 'TOGGLE_COLLAPSE': { + draftState.collapses = state.collapses.map((collapse, index) => { + if (index === action.index) { + return { ...collapse, isOpen: !collapse.isOpen }; + } + + return { ...collapse, isOpen: false }; + }); + + break; + } + default: + return draftState; + } + }); + +export { initialState, reducer }; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/Policies/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/Policies/index.js new file mode 100644 index 0000000..e9c53b3 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/Policies/index.js @@ -0,0 +1,62 @@ +import React from 'react'; + +import { Flex, GridItem, Typography } from '@strapi/design-system'; +import get from 'lodash/get'; +import isEmpty from 'lodash/isEmpty'; +import without from 'lodash/without'; +import { useIntl } from 'react-intl'; + +import { useUsersPermissions } from '../../contexts/UsersPermissionsContext'; +import BoundRoute from '../BoundRoute'; + +const Policies = () => { + const { formatMessage } = useIntl(); + const { selectedAction, routes } = useUsersPermissions(); + + const path = without(selectedAction.split('.'), 'controllers'); + const controllerRoutes = get(routes, path[0]); + const pathResolved = path.slice(1).join('.'); + + const displayedRoutes = isEmpty(controllerRoutes) + ? [] + : controllerRoutes.filter((o) => o.handler.endsWith(pathResolved)); + + return ( + + {selectedAction ? ( + + {displayedRoutes.map((route, key) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ) : ( + + + {formatMessage({ + id: 'users-permissions.Policies.header.title', + defaultMessage: 'Advanced settings', + })} + + + {formatMessage({ + id: 'users-permissions.Policies.header.hint', + defaultMessage: + "Select the application's actions or the plugin's actions and click on the cog icon to display the bound route", + })} + + + )} + + ); +}; + +export default Policies; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/index.js new file mode 100644 index 0000000..6f40fa3 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/index.js @@ -0,0 +1,95 @@ +import React, { forwardRef, memo, useImperativeHandle, useReducer } from 'react'; + +import { Flex, Grid, GridItem, Typography } from '@strapi/design-system'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { UsersPermissionsProvider } from '../../contexts/UsersPermissionsContext'; +import getTrad from '../../utils/getTrad'; +import Permissions from '../Permissions'; +import Policies from '../Policies'; + +import init from './init'; +import reducer, { initialState } from './reducer'; + +const UsersPermissions = forwardRef(({ permissions, routes }, ref) => { + const { formatMessage } = useIntl(); + const [state, dispatch] = useReducer(reducer, initialState, (state) => + init(state, permissions, routes) + ); + + useImperativeHandle(ref, () => ({ + getPermissions() { + return { + permissions: state.modifiedData, + }; + }, + resetForm() { + dispatch({ type: 'ON_RESET' }); + }, + setFormAfterSubmit() { + dispatch({ type: 'ON_SUBMIT_SUCCEEDED' }); + }, + })); + + const handleChange = ({ target: { name, value } }) => + dispatch({ + type: 'ON_CHANGE', + keys: name.split('.'), + value: value === 'empty__string_value' ? '' : value, + }); + + const handleChangeSelectAll = ({ target: { name, value } }) => + dispatch({ + type: 'ON_CHANGE_SELECT_ALL', + keys: name.split('.'), + value, + }); + + const handleSelectedAction = (actionToSelect) => + dispatch({ + type: 'SELECT_ACTION', + actionToSelect, + }); + + const providerValue = { + ...state, + onChange: handleChange, + onChangeSelectAll: handleChangeSelectAll, + onSelectedAction: handleSelectedAction, + }; + + return ( + + + + + + + {formatMessage({ + id: getTrad('Plugins.header.title'), + defaultMessage: 'Permissions', + })} + + + {formatMessage({ + id: getTrad('Plugins.header.description'), + defaultMessage: 'Only actions bound by a route are listed below.', + })} + + + + + + + + + ); +}); + +UsersPermissions.propTypes = { + permissions: PropTypes.object.isRequired, + routes: PropTypes.object.isRequired, +}; + +export default memo(UsersPermissions); diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/init.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/init.js new file mode 100644 index 0000000..e124042 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/init.js @@ -0,0 +1,10 @@ +const init = (state, permissions, routes) => { + return { + ...state, + initialData: permissions, + modifiedData: permissions, + routes, + }; +}; + +export default init; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/reducer.js b/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/reducer.js new file mode 100644 index 0000000..8a03a18 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/reducer.js @@ -0,0 +1,62 @@ +/* eslint-disable consistent-return */ +import produce from 'immer'; +import get from 'lodash/get'; +import set from 'lodash/set'; +import take from 'lodash/take'; + +export const initialState = { + initialData: {}, + modifiedData: {}, + routes: {}, + selectedAction: '', + policies: [], +}; + +const reducer = (state, action) => + produce(state, (draftState) => { + switch (action.type) { + case 'ON_CHANGE': { + const keysLength = action.keys.length; + const isChangingCheckbox = action.keys[keysLength - 1] === 'enabled'; + + if (action.value && isChangingCheckbox) { + const selectedAction = take(action.keys, keysLength - 1).join('.'); + draftState.selectedAction = selectedAction; + } + + set(draftState, ['modifiedData', ...action.keys], action.value); + break; + } + case 'ON_CHANGE_SELECT_ALL': { + const pathToValue = ['modifiedData', ...action.keys]; + const oldValues = get(state, pathToValue, {}); + const updatedValues = Object.keys(oldValues).reduce((acc, current) => { + acc[current] = { ...oldValues[current], enabled: action.value }; + + return acc; + }, {}); + + set(draftState, pathToValue, updatedValues); + + break; + } + case 'ON_RESET': { + draftState.modifiedData = state.initialData; + break; + } + case 'ON_SUBMIT_SUCCEEDED': { + draftState.initialData = state.modifiedData; + break; + } + + case 'SELECT_ACTION': { + const { actionToSelect } = action; + draftState.selectedAction = actionToSelect === state.selectedAction ? '' : actionToSelect; + break; + } + default: + return draftState; + } + }); + +export default reducer; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/contexts/UsersPermissionsContext/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/contexts/UsersPermissionsContext/index.js new file mode 100644 index 0000000..ce66c34 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/contexts/UsersPermissionsContext/index.js @@ -0,0 +1,18 @@ +import React, { createContext, useContext } from 'react'; + +import PropTypes from 'prop-types'; + +const UsersPermissions = createContext({}); + +const UsersPermissionsProvider = ({ children, value }) => { + return {children}; +}; + +const useUsersPermissions = () => useContext(UsersPermissions); + +UsersPermissionsProvider.propTypes = { + children: PropTypes.node.isRequired, + value: PropTypes.object.isRequired, +}; + +export { UsersPermissions, UsersPermissionsProvider, useUsersPermissions }; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/index.js new file mode 100644 index 0000000..9988733 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/index.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as useForm } from './useForm'; +export { default as useRolesList } from './useRolesList'; +export * from './usePlugins'; +export { default as useFetchRole } from './useFetchRole'; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/index.js new file mode 100644 index 0000000..629e001 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/index.js @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react'; + +import { useFetchClient, useNotification } from '@strapi/helper-plugin'; + +import pluginId from '../../pluginId'; + +import reducer, { initialState } from './reducer'; + +const useFetchRole = (id) => { + const [state, dispatch] = useReducer(reducer, initialState); + const toggleNotification = useNotification(); + const isMounted = useRef(null); + const { get } = useFetchClient(); + + useEffect(() => { + isMounted.current = true; + + if (id) { + fetchRole(id); + } else { + dispatch({ + type: 'GET_DATA_SUCCEEDED', + role: {}, + }); + } + + return () => (isMounted.current = false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const fetchRole = async (roleId) => { + try { + const { + data: { role }, + } = await get(`/${pluginId}/roles/${roleId}`); + + // Prevent updating state on an unmounted component + if (isMounted.current) { + dispatch({ + type: 'GET_DATA_SUCCEEDED', + role, + }); + } + } catch (err) { + console.error(err); + + dispatch({ + type: 'GET_DATA_ERROR', + }); + toggleNotification({ + type: 'warning', + message: { id: 'notification.error' }, + }); + } + }; + + const handleSubmitSucceeded = useCallback((data) => { + dispatch({ + type: 'ON_SUBMIT_SUCCEEDED', + ...data, + }); + }, []); + + return { ...state, onSubmitSucceeded: handleSubmitSucceeded }; +}; + +export default useFetchRole; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/reducer.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/reducer.js new file mode 100644 index 0000000..99dcf0d --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/reducer.js @@ -0,0 +1,31 @@ +/* eslint-disable consistent-return */ +import produce from 'immer'; + +export const initialState = { + role: {}, + isLoading: true, +}; + +const reducer = (state, action) => + produce(state, (draftState) => { + switch (action.type) { + case 'GET_DATA_SUCCEEDED': { + draftState.role = action.role; + draftState.isLoading = false; + break; + } + case 'GET_DATA_ERROR': { + draftState.isLoading = false; + break; + } + case 'ON_SUBMIT_SUCCEEDED': { + draftState.role.name = action.name; + draftState.role.description = action.description; + break; + } + default: + return draftState; + } + }); + +export default reducer; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/index.js new file mode 100644 index 0000000..0cf0ef6 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/index.js @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react'; + +import { useFetchClient, useNotification, useRBAC } from '@strapi/helper-plugin'; + +import { getRequestURL } from '../../utils'; + +import reducer, { initialState } from './reducer'; + +const useUserForm = (endPoint, permissions) => { + const { isLoading: isLoadingForPermissions, allowedActions } = useRBAC(permissions); + const [{ isLoading, modifiedData }, dispatch] = useReducer(reducer, initialState); + const toggleNotification = useNotification(); + const isMounted = useRef(true); + + const { get } = useFetchClient(); + + useEffect(() => { + const getData = async () => { + try { + dispatch({ + type: 'GET_DATA', + }); + + const { data } = await get(getRequestURL(endPoint)); + + dispatch({ + type: 'GET_DATA_SUCCEEDED', + data, + }); + } catch (err) { + // The user aborted the request + if (isMounted.current) { + dispatch({ + type: 'GET_DATA_ERROR', + }); + console.error(err); + toggleNotification({ + type: 'warning', + message: { id: 'notification.error' }, + }); + } + } + }; + + if (!isLoadingForPermissions) { + getData(); + } + + return () => { + isMounted.current = false; + }; + }, [isLoadingForPermissions, endPoint, get, toggleNotification]); + + const dispatchSubmitSucceeded = useCallback((data) => { + dispatch({ + type: 'ON_SUBMIT_SUCCEEDED', + data, + }); + }, []); + + return { + allowedActions, + dispatchSubmitSucceeded, + isLoading, + isLoadingForPermissions, + modifiedData, + }; +}; + +export default useUserForm; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/reducer.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/reducer.js new file mode 100644 index 0000000..1d05786 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/reducer.js @@ -0,0 +1,40 @@ +import produce from 'immer'; + +const initialState = { + isLoading: true, + modifiedData: {}, +}; + +const reducer = (state, action) => + // eslint-disable-next-line consistent-return + produce(state, (draftState) => { + switch (action.type) { + case 'GET_DATA': { + draftState.isLoading = true; + draftState.modifiedData = {}; + + break; + } + case 'GET_DATA_SUCCEEDED': { + draftState.isLoading = false; + draftState.modifiedData = action.data; + + break; + } + case 'GET_DATA_ERROR': { + draftState.isLoading = true; + break; + } + case 'ON_SUBMIT_SUCCEEDED': { + draftState.modifiedData = action.data; + + break; + } + default: { + return draftState; + } + } + }); + +export default reducer; +export { initialState }; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/usePlugins.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/usePlugins.js new file mode 100644 index 0000000..ce1f0e8 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/usePlugins.js @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; + +import { useNotification, useFetchClient, useAPIErrorHandler } from '@strapi/helper-plugin'; +import { useQueries } from 'react-query'; + +import pluginId from '../pluginId'; +import { cleanPermissions, getTrad } from '../utils'; + +export const usePlugins = () => { + const toggleNotification = useNotification(); + const { get } = useFetchClient(); + const { formatAPIError } = useAPIErrorHandler(getTrad); + + const [ + { + data: permissions, + isLoading: isLoadingPermissions, + error: permissionsError, + refetch: refetchPermissions, + }, + { data: routes, isLoading: isLoadingRoutes, error: routesError, refetch: refetchRoutes }, + ] = useQueries([ + { + queryKey: [pluginId, 'permissions'], + async queryFn() { + const res = await get(`/${pluginId}/permissions`); + + return res.data.permissions; + }, + }, + { + queryKey: [pluginId, 'routes'], + async queryFn() { + const res = await get(`/${pluginId}/routes`); + + return res.data.routes; + }, + }, + ]); + + const refetchQueries = async () => { + await Promise.all([refetchPermissions(), refetchRoutes()]); + }; + + useEffect(() => { + if (permissionsError) { + toggleNotification({ + type: 'warning', + message: formatAPIError(permissionsError), + }); + } + }, [toggleNotification, permissionsError, formatAPIError]); + + useEffect(() => { + if (routesError) { + toggleNotification({ + type: 'warning', + message: formatAPIError(routesError), + }); + } + }, [toggleNotification, routesError, formatAPIError]); + + const isLoading = isLoadingPermissions || isLoadingRoutes; + + return { + permissions: permissions ? cleanPermissions(permissions) : {}, + routes: routes ?? {}, + getData: refetchQueries, + isLoading, + }; +}; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/index.js new file mode 100644 index 0000000..aae9175 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/index.js @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useReducer, useRef } from 'react'; + +import { useFetchClient, useNotification } from '@strapi/helper-plugin'; +import get from 'lodash/get'; + +import pluginId from '../../pluginId'; + +import init from './init'; +import reducer, { initialState } from './reducer'; + +const useRolesList = (shouldFetchData = true) => { + const [{ roles, isLoading }, dispatch] = useReducer(reducer, initialState, () => + init(initialState, shouldFetchData) + ); + const toggleNotification = useNotification(); + + const isMounted = useRef(true); + const fetchClient = useFetchClient(); + + const fetchRolesList = useCallback(async () => { + try { + dispatch({ + type: 'GET_DATA', + }); + + const { + data: { roles }, + } = await fetchClient.get(`/${pluginId}/roles`); + + dispatch({ + type: 'GET_DATA_SUCCEEDED', + data: roles, + }); + } catch (err) { + const message = get(err, ['response', 'payload', 'message'], 'An error occured'); + + if (isMounted.current) { + dispatch({ + type: 'GET_DATA_ERROR', + }); + + if (message !== 'Forbidden') { + toggleNotification({ + type: 'warning', + message, + }); + } + } + } + }, [fetchClient, toggleNotification]); + + useEffect(() => { + if (shouldFetchData) { + fetchRolesList(); + } + + return () => { + isMounted.current = false; + }; + }, [shouldFetchData, fetchRolesList]); + + return { roles, isLoading, getData: fetchRolesList }; +}; + +export default useRolesList; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/init.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/init.js new file mode 100644 index 0000000..dfe71d9 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/init.js @@ -0,0 +1,5 @@ +const init = (initialState, shouldFetchData) => { + return { ...initialState, isLoading: shouldFetchData }; +}; + +export default init; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/reducer.js b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/reducer.js new file mode 100644 index 0000000..a6d347b --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/reducer.js @@ -0,0 +1,31 @@ +/* eslint-disable consistent-return */ +import produce from 'immer'; + +export const initialState = { + roles: [], + isLoading: true, +}; + +const reducer = (state, action) => + produce(state, (draftState) => { + switch (action.type) { + case 'GET_DATA': { + draftState.isLoading = true; + draftState.roles = []; + break; + } + case 'GET_DATA_SUCCEEDED': { + draftState.roles = action.data; + draftState.isLoading = false; + break; + } + case 'GET_DATA_ERROR': { + draftState.isLoading = false; + break; + } + default: + return draftState; + } + }); + +export default reducer; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/index.js new file mode 100644 index 0000000..ba721ed --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/index.js @@ -0,0 +1,125 @@ +// NOTE TO PLUGINS DEVELOPERS: +// If you modify this file by adding new options to the plugin entry point +// Here's the file: strapi/docs/3.0.0-beta.x/plugin-development/frontend-field-api.md +// Here's the file: strapi/docs/3.0.0-beta.x/guides/registering-a-field-in-admin.md +// Also the strapi-generate-plugins/files/admin/src/index.js needs to be updated +// IF THE DOC IS NOT UPDATED THE PULL REQUEST WILL NOT BE MERGED +import { prefixPluginTranslations } from '@strapi/helper-plugin'; + +import pluginPkg from '../../package.json'; + +import pluginPermissions from './permissions'; +import pluginId from './pluginId'; +import getTrad from './utils/getTrad'; + +const name = pluginPkg.strapi.name; + +export default { + register(app) { + // Create the plugin's settings section + app.createSettingSection( + { + id: pluginId, + intlLabel: { + id: getTrad('Settings.section-label'), + defaultMessage: 'Users & Permissions plugin', + }, + }, + [ + { + intlLabel: { + id: 'global.roles', + defaultMessage: 'Roles', + }, + id: 'roles', + to: `/settings/${pluginId}/roles`, + async Component() { + const component = await import( + /* webpackChunkName: "users-roles-settings-page" */ './pages/Roles' + ); + + return component; + }, + permissions: pluginPermissions.accessRoles, + }, + { + intlLabel: { + id: getTrad('HeaderNav.link.providers'), + defaultMessage: 'Providers', + }, + id: 'providers', + to: `/settings/${pluginId}/providers`, + async Component() { + const component = await import( + /* webpackChunkName: "users-providers-settings-page" */ './pages/Providers' + ); + + return component; + }, + permissions: pluginPermissions.readProviders, + }, + { + intlLabel: { + id: getTrad('HeaderNav.link.emailTemplates'), + defaultMessage: 'Email templates', + }, + id: 'email-templates', + to: `/settings/${pluginId}/email-templates`, + async Component() { + const component = await import( + /* webpackChunkName: "users-email-settings-page" */ './pages/EmailTemplates' + ); + + return component; + }, + permissions: pluginPermissions.readEmailTemplates, + }, + { + intlLabel: { + id: getTrad('HeaderNav.link.advancedSettings'), + defaultMessage: 'Advanced Settings', + }, + id: 'advanced-settings', + to: `/settings/${pluginId}/advanced-settings`, + async Component() { + const component = await import( + /* webpackChunkName: "users-advanced-settings-page" */ './pages/AdvancedSettings' + ); + + return component; + }, + permissions: pluginPermissions.readAdvancedSettings, + }, + ] + ); + + app.registerPlugin({ + id: pluginId, + name, + }); + }, + bootstrap() {}, + async registerTrads({ locales }) { + const importedTrads = await Promise.all( + locales.map((locale) => { + return import( + /* webpackChunkName: "users-permissions-translation-[request]" */ `./translations/${locale}.json` + ) + .then(({ default: data }) => { + return { + data: prefixPluginTranslations(data, pluginId), + locale, + }; + }) + .catch(() => { + return { + data: {}, + locale, + }; + }); + }) + ); + + return Promise.resolve(importedTrads); + }, +}; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/index.js b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/index.js new file mode 100644 index 0000000..3f0726f --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/index.js @@ -0,0 +1,246 @@ +import React, { useMemo } from 'react'; + +import { + Box, + Button, + ContentLayout, + Flex, + Grid, + GridItem, + HeaderLayout, + Main, + Option, + Select, + Typography, + useNotifyAT, +} from '@strapi/design-system'; +import { + CheckPagePermissions, + Form, + GenericInput, + LoadingIndicatorPage, + SettingsPageTitle, + useFocusWhenNavigate, + useNotification, + useOverlayBlocker, + useRBAC, +} from '@strapi/helper-plugin'; +import { Check } from '@strapi/icons'; +import { Formik } from 'formik'; +import { useIntl } from 'react-intl'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; + +import pluginPermissions from '../../permissions'; +import { getTrad } from '../../utils'; + +import { fetchData, putAdvancedSettings } from './utils/api'; +import layout from './utils/layout'; +import schema from './utils/schema'; + +const ProtectedAdvancedSettingsPage = () => ( + + + +); + +const AdvancedSettingsPage = () => { + const { formatMessage } = useIntl(); + const toggleNotification = useNotification(); + const { lockApp, unlockApp } = useOverlayBlocker(); + const { notifyStatus } = useNotifyAT(); + const queryClient = useQueryClient(); + useFocusWhenNavigate(); + + const updatePermissions = useMemo( + () => ({ update: pluginPermissions.updateAdvancedSettings }), + [] + ); + const { + isLoading: isLoadingForPermissions, + allowedActions: { canUpdate }, + } = useRBAC(updatePermissions); + + const { status: isLoadingData, data } = useQuery('advanced', () => fetchData(), { + onSuccess() { + notifyStatus( + formatMessage({ + id: getTrad('Form.advancedSettings.data.loaded'), + defaultMessage: 'Advanced settings data has been loaded', + }) + ); + }, + onError() { + toggleNotification({ + type: 'warning', + message: { id: getTrad('notification.error'), defaultMessage: 'An error occured' }, + }); + }, + }); + + const isLoading = isLoadingForPermissions || isLoadingData !== 'success'; + + const submitMutation = useMutation((body) => putAdvancedSettings(body), { + async onSuccess() { + await queryClient.invalidateQueries('advanced'); + toggleNotification({ + type: 'success', + message: { id: getTrad('notification.success.saved'), defaultMessage: 'Saved' }, + }); + + unlockApp(); + }, + onError() { + toggleNotification({ + type: 'warning', + message: { id: getTrad('notification.error'), defaultMessage: 'An error occured' }, + }); + unlockApp(); + }, + refetchActive: true, + }); + + const { isLoading: isSubmittingForm } = submitMutation; + + const handleSubmit = async (body) => { + lockApp(); + + const urlConfirmation = body.email_confirmation ? body.email_confirmation_redirection : ''; + + await submitMutation.mutateAsync({ ...body, email_confirmation_redirection: urlConfirmation }); + }; + + if (isLoading) { + return ( +
+ + + + + +
+ ); + } + + return ( +
+ + + {({ errors, values, handleChange, isSubmitting }) => { + return ( +
+ } + size="S" + > + {formatMessage({ id: 'global.save', defaultMessage: 'Save' })} + + } + /> + + + + + {formatMessage({ + id: 'global.settings', + defaultMessage: 'Settings', + })} + + + + + + {layout.map((input) => { + let value = values[input.name]; + + if (!value) { + value = input.type === 'bool' ? false : ''; + } + + return ( + + + + ); + })} + + + + + + ); + }} +
+
+ ); +}; + +export default ProtectedAdvancedSettingsPage; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/api.js b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/api.js new file mode 100644 index 0000000..238862a --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/api.js @@ -0,0 +1,18 @@ +import { getFetchClient } from '@strapi/helper-plugin'; + +import { getRequestURL } from '../../../utils'; + +const fetchData = async () => { + const { get } = getFetchClient(); + const { data } = await get(getRequestURL('advanced')); + + return data; +}; + +const putAdvancedSettings = (body) => { + const { put } = getFetchClient(); + + return put(getRequestURL('advanced'), body); +}; + +export { fetchData, putAdvancedSettings }; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/layout.js b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/layout.js new file mode 100644 index 0000000..094e5a6 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/layout.js @@ -0,0 +1,96 @@ +import { getTrad } from '../../../utils'; + +const layout = [ + { + intlLabel: { + id: getTrad('EditForm.inputToggle.label.email'), + defaultMessage: 'One account per email address', + }, + description: { + id: getTrad('EditForm.inputToggle.description.email'), + defaultMessage: + 'Disallow the user to create multiple accounts using the same email address with different authentication providers.', + }, + name: 'unique_email', + type: 'bool', + size: { + col: 12, + xs: 12, + }, + }, + { + intlLabel: { + id: getTrad('EditForm.inputToggle.label.sign-up'), + defaultMessage: 'Enable sign-ups', + }, + description: { + id: getTrad('EditForm.inputToggle.description.sign-up'), + defaultMessage: + 'When disabled (OFF), the registration process is forbidden. No one can subscribe anymore no matter the used provider.', + }, + name: 'allow_register', + type: 'bool', + size: { + col: 12, + xs: 12, + }, + }, + { + intlLabel: { + id: getTrad('EditForm.inputToggle.label.email-reset-password'), + defaultMessage: 'Reset password page', + }, + description: { + id: getTrad('EditForm.inputToggle.description.email-reset-password'), + defaultMessage: "URL of your application's reset password page.", + }, + placeholder: { + id: getTrad('EditForm.inputToggle.placeholder.email-reset-password'), + defaultMessage: 'ex: https://youtfrontend.com/reset-password', + }, + name: 'email_reset_password', + type: 'text', + size: { + col: 6, + xs: 12, + }, + }, + { + intlLabel: { + id: getTrad('EditForm.inputToggle.label.email-confirmation'), + defaultMessage: 'Enable email confirmation', + }, + description: { + id: getTrad('EditForm.inputToggle.description.email-confirmation'), + defaultMessage: 'When enabled (ON), new registered users receive a confirmation email.', + }, + name: 'email_confirmation', + type: 'bool', + size: { + col: 12, + xs: 12, + }, + }, + { + intlLabel: { + id: getTrad('EditForm.inputToggle.label.email-confirmation-redirection'), + defaultMessage: 'Redirection url', + }, + description: { + id: getTrad('EditForm.inputToggle.description.email-confirmation-redirection'), + defaultMessage: 'After you confirmed your email, choose where you will be redirected.', + }, + placeholder: { + id: getTrad('EditForm.inputToggle.placeholder.email-confirmation-redirection'), + defaultMessage: 'ex: https://youtfrontend.com/email-confirmation', + }, + name: 'email_confirmation_redirection', + type: 'text', + size: { + col: 6, + xs: 12, + }, + }, +]; + +export default layout; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/schema.js b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/schema.js new file mode 100644 index 0000000..b8958a8 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/schema.js @@ -0,0 +1,19 @@ +import { translatedErrors } from '@strapi/helper-plugin'; +import * as yup from 'yup'; + +// eslint-disable-next-line prefer-regex-literals +const URL_REGEX = new RegExp('(^$)|((.+:\\/\\/.*)(d*)\\/?(.*))'); + +const schema = yup.object().shape({ + email_confirmation_redirection: yup.mixed().when('email_confirmation', { + is: true, + then: yup.string().matches(URL_REGEX).required(), + otherwise: yup.string().nullable(), + }), + email_reset_password: yup + .string(translatedErrors.string) + .matches(URL_REGEX, translatedErrors.regex) + .nullable(), +}); + +export default schema; diff --git a/packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/components/EmailForm.js b/packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/components/EmailForm.js new file mode 100644 index 0000000..5d98122 --- /dev/null +++ b/packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/components/EmailForm.js @@ -0,0 +1,176 @@ +import React from 'react'; + +import { + Button, + Grid, + GridItem, + ModalBody, + ModalFooter, + ModalHeader, + ModalLayout, + Textarea, +} from '@strapi/design-system'; +import { Breadcrumbs, Crumb } from '@strapi/design-system/v2'; +import { Form, GenericInput } from '@strapi/helper-plugin'; +import { Formik } from 'formik'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; + +import { getTrad } from '../../../utils'; +import schema from '../utils/schema'; + +const EmailForm = ({ template, onToggle, onSubmit }) => { + const { formatMessage } = useIntl(); + + return ( + + + + + {formatMessage({ + id: getTrad('PopUpForm.header.edit.email-templates'), + defaultMessage: 'Edit email template', + })} + + + {formatMessage({ id: getTrad(template.display), defaultMessage: template.display })} + + + + + {({ errors, values, handleChange, isSubmitting }) => { + return ( +
+ + + + + + + + + + + + + + + +