From d60c6ac3bba5164d0db448579efdd4388fe2eee1 Mon Sep 17 00:00:00 2001
From: Chris Grimmett
Date: Sat, 20 Jan 2024 08:16:14 -0800
Subject: [PATCH] init
---
.dockerignore | 15 +
.gitignore | 148 +
Dockerfile | 19 +
compose.prod.yml | 103 +
compose.yml | 190 +
packages/next/.eslintrc.json | 3 +
packages/next/.gitignore | 47 +
packages/next/.nvmrc | 1 +
packages/next/CHECKS | 1 +
packages/next/Dockerfile.old | 35 +
packages/next/LICENSE | 21 +
packages/next/README.md | 27 +
packages/next/app.json | 14 +
packages/next/app/about/page.tsx | 64 +
packages/next/app/api/blogs/route.ts | 10 +
packages/next/app/api/page.tsx | 118 +
packages/next/app/api/revalidate/route.ts | 26 +
packages/next/app/api/service.json/route.ts | 139 +
packages/next/app/api/v1.json/route.ts | 91 +
.../page.tsx | 54 +
packages/next/app/blog/page.tsx | 35 +
.../next/app/components/archive-progress.tsx | 23 +
packages/next/app/components/auth.tsx | 131 +
packages/next/app/components/cal.tsx | 125 +
packages/next/app/components/contributors.tsx | 33 +
.../next/app/components/custom-hls-player.tsx | 80 +
packages/next/app/components/footer.tsx | 118 +
packages/next/app/components/funding-goal.tsx | 81 +
packages/next/app/components/icons/carrd.tsx | 8 +
.../next/app/components/icons/chaturbate.tsx | 14 +
packages/next/app/components/icons/fansly.tsx | 20 +
.../next/app/components/icons/linktree.tsx | 30 +
.../next/app/components/icons/onlyfans.tsx | 55 +
.../next/app/components/icons/pornhub.tsx | 23 +
packages/next/app/components/icons/throne.tsx | 31 +
packages/next/app/components/ipfs-cid.tsx | 42 +
packages/next/app/components/ipfs-logo.tsx | 18 +
packages/next/app/components/ipfs.tsx | 39 +
.../next/app/components/linkable-heading.tsx | 28 +
.../next/app/components/localized-date.tsx | 15 +
packages/next/app/components/navbar.tsx | 98 +
.../app/components/notification-center.tsx | 13 +
.../next/app/components/notifications.tsx | 10 +
packages/next/app/components/pager.tsx | 82 +
packages/next/app/components/patrons-list.tsx | 55 +
.../next/app/components/sortable-tags.tsx | 70 +
.../next/app/components/stream-button.tsx | 19 +
packages/next/app/components/stream-page.tsx | 187 +
packages/next/app/components/stream.tsx | 86 +
.../next/app/components/streams-calendar.tsx | 83 +
packages/next/app/components/streams-list.tsx | 61 +
packages/next/app/components/tag-button.tsx | 8 +
packages/next/app/components/tag.tsx | 57 +
packages/next/app/components/tagger.tsx | 240 +
.../next/app/components/timestamps-list.tsx | 72 +
packages/next/app/components/toys.tsx | 81 +
packages/next/app/components/upload-form.tsx | 327 +
.../next/app/components/user-controls.tsx | 229 +
.../next/app/components/video-context.tsx | 56 +
.../next/app/components/video-interactive.tsx | 134 +
packages/next/app/components/video-player.tsx | 151 +
.../app/components/video-source-selector.tsx | 130 +
packages/next/app/components/vod-card.tsx | 72 +
packages/next/app/components/vod-nav.tsx | 90 +
packages/next/app/components/vod-page.tsx | 96 +
packages/next/app/components/vods-list.tsx | 66 +
.../next/app/components/vtuber-button.tsx | 29 +
packages/next/app/components/vtuber-card.tsx | 43 +
.../app/connect/patreon/redirect/page.tsx | 123 +
packages/next/app/faq/page.tsx | 104 +
packages/next/app/favicon.ico | Bin 0 -> 318 bytes
packages/next/app/feed/feed.json/route.ts | 11 +
packages/next/app/feed/feed.xml/route.ts | 11 +
packages/next/app/feed/page.tsx | 28 +
packages/next/app/feed/rss.xml/route.ts | 11 +
packages/next/app/goals/page.tsx | 75 +
packages/next/app/health/page.tsx | 12 +
packages/next/app/latest-vods/[page]/page.tsx | 32 +
packages/next/app/latest-vods/page.tsx | 24 +
packages/next/app/layout.tsx | 70 +
packages/next/app/lib/b2File.ts | 15 +
packages/next/app/lib/blog.ts | 5 +
packages/next/app/lib/constants.ts | 20 +
packages/next/app/lib/contributors.ts | 24 +
packages/next/app/lib/dates.ts | 59 +
packages/next/app/lib/fetch-api.ts | 34 +
packages/next/app/lib/fetchers.ts | 32 +
packages/next/app/lib/ipfs.ts | 7 +
packages/next/app/lib/patreon.ts | 55 +
packages/next/app/lib/pm.ts | 139 +
packages/next/app/lib/retry.ts | 13 +
packages/next/app/lib/rss.ts | 51 +
packages/next/app/lib/shareRef.ts | 16 +
packages/next/app/lib/streams.ts | 369 +
packages/next/app/lib/tag-vod-relations.ts | 191 +
packages/next/app/lib/tags.ts | 139 +
packages/next/app/lib/timestamps.ts | 127 +
packages/next/app/lib/toys.ts | 42 +
packages/next/app/lib/tweets.ts | 28 +
packages/next/app/lib/types.ts | 28 +
packages/next/app/lib/useForwardRef.ts | 27 +
packages/next/app/lib/users.ts | 16 +
packages/next/app/lib/vods.ts | 502 +
packages/next/app/lib/vtubers.ts | 171 +
packages/next/app/page.tsx | 72 +
packages/next/app/patrons/page.tsx | 42 +
packages/next/app/profile/page.tsx | 62 +
.../next/app/streams/[cuid]/not-found.tsx | 12 +
packages/next/app/streams/[cuid]/page.tsx | 20 +
packages/next/app/streams/page.tsx | 34 +
packages/next/app/tags/[slug]/page.tsx | 35 +
packages/next/app/tags/page.tsx | 15 +
packages/next/app/upload/page.tsx | 26 +
packages/next/app/upload/page.tsx.old | 240 +
packages/next/app/uppy.tsx | 45 +
.../next/app/vods/[safeDateOrCuid]/page.tsx | 16 +
packages/next/app/vods/page.tsx | 6 +
packages/next/app/vt/[slug]/history/page.tsx | 70 +
packages/next/app/vt/[slug]/not-found.tsx | 12 +
packages/next/app/vt/[slug]/page.tsx | 246 +
.../app/vt/[slug]/stream/[safeDate]/page.tsx | 31 +
packages/next/app/vt/[slug]/streams/page.tsx | 23 +
.../next/app/vt/[slug]/toys/[page]/page.tsx | 33 +
packages/next/app/vt/[slug]/toys/page.tsx | 33 +
.../vt/[slug]/vod/[safeDateOrCuid]/page.tsx | 12 +
packages/next/app/vt/[slug]/vod/page.tsx | 15 +
.../next/app/vt/[slug]/vods/[page]/page.tsx | 43 +
packages/next/app/vt/[slug]/vods/page.tsx | 26 +
packages/next/app/vt/page.tsx | 30 +
.../styles/calendar-heatmap.module.scss | 89 +
packages/next/assets/styles/cid.module.css | 19 +
packages/next/assets/styles/fp.module.css | 20 +
packages/next/assets/styles/global.sass | 50 +
packages/next/assets/styles/icon.module.css | 20 +
packages/next/assets/styles/player.module.css | 4 +
packages/next/assets/svg/README.md | 5 +
packages/next/assets/svg/carrd.svg | 1 +
packages/next/assets/svg/chaturbate.svg | 15 +
packages/next/assets/svg/checkmark.svg | 5 +
packages/next/assets/svg/fansly.tsx | 20 +
packages/next/assets/svg/ipfs.svg | 1 +
packages/next/assets/svg/linktree.svg | 64 +
.../assets/svg/noun-adult-content-1731184.svg | 1 +
.../next/assets/svg/noun-anime-3890912.svg | 1 +
.../next/assets/svg/noun-avatar-3546974.svg | 1 +
packages/next/assets/svg/noun-girl-842331.svg | 4 +
.../next/assets/svg/noun-network-1603820.svg | 1 +
packages/next/assets/svg/onlyfans.svg | 9 +
packages/next/assets/svg/pornhub.svg | 5 +
packages/next/assets/svg/throne.svg | 12 +
packages/next/next.config.js | 22 +
packages/next/package.json | 79 +
packages/next/public/futureporn-icon.png | Bin 0 -> 11599 bytes
packages/next/public/images/.keep | 0
packages/next/public/images/cj_clippy.jpg | Bin 0 -> 301528 bytes
.../next/public/images/default-thumbnail.webp | Bin 0 -> 27268 bytes
.../next/public/images/projekt-melody.jpg | Bin 0 -> 134526 bytes
.../images/projektmelody-thumbnail.webp | Bin 0 -> 1356 bytes
packages/next/public/images/vercel.svg | 3 +
packages/next/tsconfig.json | 50 +
packages/strapi/.dockerignore | 8 +
packages/strapi/.gitignore | 118 +
packages/strapi/.nvmrc | 1 +
packages/strapi/.strapi/client/app.js | 14 +
packages/strapi/.strapi/client/index.html | 62 +
packages/strapi/Dockerfile | 19 +
packages/strapi/README.md | 7 +
.../strapi/backup/Dockerfile.1704607848934 | 49 +
packages/strapi/chisel.sh | 5 +
packages/strapi/config/admin.js | 13 +
packages/strapi/config/api.js | 7 +
packages/strapi/config/database.js | 49 +
packages/strapi/config/middlewares.js | 26 +
packages/strapi/config/plugins.js | 75 +
packages/strapi/config/server.js | 15 +
packages/strapi/database/daily-backup.sh | 13 +
packages/strapi/database/devDb.sh | 22 +
packages/strapi/database/migrations/.gitkeep | 0
...2023-08-01-relate-vods-to-vtubers-part2.js | 25 +
.../migrations/2023-08-17-reformat-cdnUrl.js | 18 +
.../2023-08-20-strip-query-string-from-cid.js | 23 +
.../2023-08-30-remove-cloudinary.js | 13 +
.../2023-08-30-toy-image-field-simplify.js | 33 +
.../2023-09-08-change-date-to-string.js | 23 +
.../migrations/2023-09-08-drop-toys-image.js | 11 +
.../2023-09-08-drop-vod-videosrc.js | 11 +
.../migrations/2023-12-24-add-cuid-to-vods.js | 31 +
.../2023-12-26-add-cuid-to-streams.js | 33 +
.../2023-12-27-relate-vods-to-streams.js | 35 +
.../2023.05.09-video-src-sanity.js.noexec | 26 +
...12.32.00.convert-to-video-src-b2.js.noexec | 98 +
....000Z.migrate-tags-to-tag-vod-relations.js | 43 +
.../2023.05.15T02.44.00.000Z.drop-vod-tags.js | 12 +
.../2023.05.25-gimme-the-tags.js.noexec | 110 +
...023.05.25T20.44.00.000Z.get-the-og-tags.js | 124 +
.../2023.07.17.relate-vods-to-vtubers.js | 70 +
.../2023.07.31.add-b2-file-cdnUrl.js | 18 +
.../2024-01-08-add-streams.js.noexec | 30 +
.../2024-01-14-add-date2-to-streams.js | 29 +
.../2024-01-15-add-platform-to-streams.js | 49 +
packages/strapi/database/og-tags.json | 1009 ++
packages/strapi/favicon.png | Bin 0 -> 497 bytes
.../strapi/misc/2023-05-26-export-og-tags.js | 202 +
packages/strapi/misc/generateCuid.js | 7 +
packages/strapi/package.json | 90 +
packages/strapi/public/robots.txt | 3 +
packages/strapi/public/uploads/.gitkeep | 0
packages/strapi/src/admin/app.example.js | 39 +
.../src/admin/webpack.config.example.js | 9 +
packages/strapi/src/api/.gitkeep | 0
.../b2-file/content-types/b2-file/schema.json | 36 +
.../src/api/b2-file/controllers/b2-file.js | 9 +
.../strapi/src/api/b2-file/routes/b2-file.js | 9 +
.../src/api/b2-file/services/b2-file.js | 9 +
.../content-types/contributor/schema.json | 30 +
.../contributor/controllers/contributor.js | 9 +
.../src/api/contributor/routes/contributor.js | 9 +
.../api/contributor/services/contributor.js | 9 +
.../api/goal/content-types/goal/schema.json | 30 +
.../strapi/src/api/goal/controllers/goal.js | 9 +
packages/strapi/src/api/goal/routes/goal.js | 9 +
packages/strapi/src/api/goal/services/goal.js | 11 +
.../api/gogs/content-types/gogs/schema.json | 24 +
.../strapi/src/api/gogs/controllers/gogs.js | 29 +
packages/strapi/src/api/gogs/routes/gogs.js | 34 +
packages/strapi/src/api/gogs/services/gogs.js | 42 +
.../api/issue/content-types/issue/schema.json | 36 +
.../strapi/src/api/issue/controllers/issue.js | 9 +
packages/strapi/src/api/issue/routes/issue.js | 9 +
.../strapi/src/api/issue/services/issue.js | 9 +
.../content-types/mux-asset/schema.json | 28 +
.../api/mux-asset/controllers/mux-asset.js | 56 +
.../src/api/mux-asset/routes/mux-asset.js | 33 +
.../src/api/mux-asset/services/mux-asset.js | 78 +
.../patreon/content-types/patreon/schema.json | 39 +
.../src/api/patreon/controllers/patreon.js | 37 +
.../strapi/src/api/patreon/routes/patreon.js | 37 +
.../src/api/patreon/services/patreon.js | 45 +
.../src/api/profile/controllers/profile.js | 21 +
.../strapi/src/api/profile/routes/profile.js | 23 +
.../src/api/profile/services/profile.js | 7 +
.../stream/content-types/stream/lifecycles.js | 61 +
.../stream/content-types/stream/schema.json | 73 +
.../src/api/stream/controllers/stream.js | 9 +
.../strapi/src/api/stream/routes/stream.js | 9 +
.../strapi/src/api/stream/services/stream.js | 9 +
.../tag-vod-relation/schema.json | 39 +
.../controllers/tag-vod-relation.js | 222 +
.../routes/tag-vod-relation.js | 51 +
.../services/tag-vod-relation.js | 69 +
.../src/api/tag/content-types/tag/schema.json | 38 +
.../strapi/src/api/tag/controllers/tag.js | 87 +
packages/strapi/src/api/tag/routes/tag.js | 37 +
packages/strapi/src/api/tag/services/tag.js | 46 +
.../content-types/timestamp/schema.json | 47 +
.../api/timestamp/controllers/timestamp.js | 166 +
.../src/api/timestamp/routes/timestamp.js | 53 +
.../src/api/timestamp/services/timestamp.js | 44 +
.../src/api/toy/content-types/toy/schema.json | 51 +
.../strapi/src/api/toy/controllers/toy.js | 9 +
packages/strapi/src/api/toy/routes/toy.js | 9 +
packages/strapi/src/api/toy/services/toy.js | 9 +
.../tweet/content-types/tweet/lifecycles.js | 136 +
.../api/tweet/content-types/tweet/schema.json | 49 +
.../strapi/src/api/tweet/controllers/tweet.js | 9 +
packages/strapi/src/api/tweet/routes/tweet.js | 9 +
.../strapi/src/api/tweet/services/tweet.js | 9 +
.../user-submitted-content/lifecycles.js | 58 +
.../user-submitted-content/schema.json | 36 +
.../controllers/user-submitted-content.js | 60 +
.../routes/user-submitted-content.js | 33 +
.../services/user-submitted-content.js | 9 +
.../src/api/vod/content-types/lifecycles.js | 12 +
.../src/api/vod/content-types/vod/schema.json | 142 +
.../strapi/src/api/vod/controllers/vod.js | 85 +
packages/strapi/src/api/vod/routes/vod.js | 38 +
packages/strapi/src/api/vod/services/vod.js | 9 +
.../vtuber/content-types/vtuber/lifecycles.js | 23 +
.../vtuber/content-types/vtuber/schema.json | 118 +
.../src/api/vtuber/controllers/vtuber.js | 9 +
.../strapi/src/api/vtuber/routes/vtuber.js | 9 +
.../strapi/src/api/vtuber/services/vtuber.js | 9 +
packages/strapi/src/extensions/.gitkeep | 0
.../users-permissions/.eslintignore | 2 +
.../extensions/users-permissions/.eslintrc.js | 14 +
.../src/extensions/users-permissions/LICENSE | 22 +
.../extensions/users-permissions/README.md | 1 +
.../components/BoundRoute/getMethodColor.js | 41 +
.../admin/src/components/BoundRoute/index.js | 72 +
.../src/components/FormModal/Input/index.js | 123 +
.../admin/src/components/FormModal/index.js | 126 +
.../PermissionRow/CheckboxWrapper.js | 30 +
.../Permissions/PermissionRow/SubCategory.js | 131 +
.../Permissions/PermissionRow/index.js | 55 +
.../admin/src/components/Permissions/index.js | 57 +
.../admin/src/components/Permissions/init.js | 9 +
.../src/components/Permissions/reducer.js | 27 +
.../admin/src/components/Policies/index.js | 62 +
.../src/components/UsersPermissions/index.js | 95 +
.../src/components/UsersPermissions/init.js | 10 +
.../components/UsersPermissions/reducer.js | 62 +
.../contexts/UsersPermissionsContext/index.js | 18 +
.../admin/src/hooks/index.js | 5 +
.../admin/src/hooks/useFetchRole/index.js | 67 +
.../admin/src/hooks/useFetchRole/reducer.js | 31 +
.../admin/src/hooks/useForm/index.js | 70 +
.../admin/src/hooks/useForm/reducer.js | 40 +
.../admin/src/hooks/usePlugins.js | 71 +
.../admin/src/hooks/useRolesList/index.js | 65 +
.../admin/src/hooks/useRolesList/init.js | 5 +
.../admin/src/hooks/useRolesList/reducer.js | 31 +
.../users-permissions/admin/src/index.js | 125 +
.../admin/src/pages/AdvancedSettings/index.js | 246 +
.../src/pages/AdvancedSettings/utils/api.js | 18 +
.../pages/AdvancedSettings/utils/layout.js | 96 +
.../pages/AdvancedSettings/utils/schema.js | 19 +
.../EmailTemplates/components/EmailForm.js | 176 +
.../EmailTemplates/components/EmailTable.js | 128 +
.../admin/src/pages/EmailTemplates/index.js | 163 +
.../src/pages/EmailTemplates/utils/api.js | 18 +
.../src/pages/EmailTemplates/utils/schema.js | 22 +
.../admin/src/pages/Providers/index.js | 275 +
.../admin/src/pages/Providers/reducer.js | 54 +
.../admin/src/pages/Providers/utils/api.js | 26 +
.../Providers/utils/createProvidersArray.js | 21 +
.../admin/src/pages/Providers/utils/forms.js | 259 +
.../admin/src/pages/Roles/CreatePage.js | 185 +
.../admin/src/pages/Roles/EditPage.js | 197 +
.../Roles/ListPage/components/TableBody.js | 93 +
.../admin/src/pages/Roles/ListPage/index.js | 243 +
.../src/pages/Roles/ListPage/utils/api.js | 32 +
.../src/pages/Roles/ProtectedCreatePage.js | 15 +
.../src/pages/Roles/ProtectedEditPage.js | 15 +
.../src/pages/Roles/ProtectedListPage.js | 17 +
.../admin/src/pages/Roles/constants.js | 7 +
.../admin/src/pages/Roles/index.js | 30 +
.../admin/src/permissions.js | 31 +
.../users-permissions/admin/src/pluginId.js | 5 +
.../admin/src/translations/ar.json | 40 +
.../admin/src/translations/cs.json | 46 +
.../admin/src/translations/de.json | 58 +
.../admin/src/translations/dk.json | 82 +
.../admin/src/translations/en.json | 82 +
.../admin/src/translations/es.json | 82 +
.../admin/src/translations/fr.json | 46 +
.../admin/src/translations/id.json | 58 +
.../admin/src/translations/it.json | 58 +
.../admin/src/translations/ja.json | 44 +
.../admin/src/translations/ko.json | 82 +
.../admin/src/translations/ms.json | 45 +
.../admin/src/translations/nl.json | 44 +
.../admin/src/translations/pl.json | 82 +
.../admin/src/translations/pt-BR.json | 40 +
.../admin/src/translations/pt.json | 44 +
.../admin/src/translations/ru.json | 82 +
.../admin/src/translations/sk.json | 46 +
.../admin/src/translations/sv.json | 82 +
.../admin/src/translations/th.json | 56 +
.../admin/src/translations/tr.json | 81 +
.../admin/src/translations/uk.json | 45 +
.../admin/src/translations/vi.json | 46 +
.../admin/src/translations/zh-Hans.json | 82 +
.../admin/src/translations/zh.json | 82 +
.../admin/src/utils/cleanPermissions.js | 25 +
.../admin/src/utils/formatPluginName.js | 26 +
.../admin/src/utils/formatPolicies.js | 8 +
.../admin/src/utils/getRequestURL.js | 5 +
.../admin/src/utils/getTrad.js | 5 +
.../admin/src/utils/index.js | 4 +
.../content-types/user/schema.json | 88 +
.../documentation/content-api.yaml | 878 ++
.../users-permissions/jest.config.front.js | 7 +
.../extensions/users-permissions/package.json | 81 +
.../server/bootstrap/grant-config.js | 131 +
.../server/bootstrap/index.js | 133 +
.../bootstrap/users-permissions-actions.js | 80 +
.../users-permissions/server/config.js | 23 +
.../server/content-types/index.js | 11 +
.../server/content-types/permission/index.js | 34 +
.../server/content-types/role/index.js | 51 +
.../server/content-types/user/index.js | 75 +
.../content-types/user/schema-config.js | 15 +
.../server/controllers/auth.js | 416 +
.../controllers/content-manager-user.js | 175 +
.../server/controllers/index.js | 17 +
.../server/controllers/permissions.js | 26 +
.../server/controllers/role.js | 77 +
.../server/controllers/settings.js | 85 +
.../server/controllers/user.js | 209 +
.../server/controllers/validation/auth.js | 57 +
.../controllers/validation/email-template.js | 74 +
.../server/controllers/validation/user.js | 59 +
.../users-permissions/server/graphql/index.js | 44 +
.../graphql/mutations/auth/change-password.js | 38 +
.../mutations/auth/email-confirmation.js | 39 +
.../graphql/mutations/auth/forgot-password.js | 35 +
.../server/graphql/mutations/auth/login.js | 35 +
.../server/graphql/mutations/auth/register.js | 36 +
.../graphql/mutations/auth/reset-password.js | 38 +
.../mutations/crud/role/create-role.js | 34 +
.../mutations/crud/role/delete-role.js | 25 +
.../mutations/crud/role/update-role.js | 35 +
.../mutations/crud/user/create-user.js | 45 +
.../mutations/crud/user/delete-user.js | 39 +
.../mutations/crud/user/update-user.js | 46 +
.../server/graphql/mutations/index.js | 43 +
.../server/graphql/queries/index.js | 13 +
.../server/graphql/queries/me.js | 17 +
.../server/graphql/resolvers-configs.js | 42 +
.../graphql/types/create-role-payload.js | 11 +
.../graphql/types/delete-role-payload.js | 11 +
.../server/graphql/types/index.js | 21 +
.../server/graphql/types/login-input.js | 13 +
.../server/graphql/types/login-payload.js | 12 +
.../server/graphql/types/me-role.js | 14 +
.../server/graphql/types/me.js | 16 +
.../server/graphql/types/password-payload.js | 11 +
.../server/graphql/types/register-input.js | 13 +
.../graphql/types/update-role-payload.js | 11 +
.../users-permissions/server/graphql/utils.js | 27 +
.../users-permissions/server/index.js | 21 +
.../server/middlewares/index.js | 7 +
.../server/middlewares/rateLimit.js | 27 +
.../users-permissions/server/register.js | 29 +
.../server/routes/admin/index.js | 10 +
.../server/routes/admin/permissions.js | 20 +
.../server/routes/admin/role.js | 79 +
.../server/routes/admin/settings.js | 95 +
.../server/routes/content-api/auth.js | 82 +
.../server/routes/content-api/index.js | 11 +
.../server/routes/content-api/permissions.js | 9 +
.../server/routes/content-api/role.js | 29 +
.../server/routes/content-api/user.js | 60 +
.../users-permissions/server/routes/index.js | 6 +
.../server/services/index.js | 19 +
.../users-permissions/server/services/jwt.js | 79 +
.../server/services/permission.js | 45 +
.../server/services/providers-registry.js | 382 +
.../server/services/providers.js | 156 +
.../users-permissions/server/services/role.js | 177 +
.../users-permissions/server/services/user.js | 148 +
.../server/services/users-permissions.js | 249 +
.../server/strategies/users-permissions.js | 114 +
.../users-permissions/server/utils/index.d.ts | 18 +
.../users-permissions/server/utils/index.js | 12 +
.../server/utils/sanitize/index.js | 9 +
.../server/utils/sanitize/sanitizers.js | 19 +
.../server/utils/sanitize/visitors/index.js | 5 +
...remove-user-relation-from-role-entities.js | 11 +
.../users-permissions/strapi-admin.js | 3 +
.../users-permissions/strapi-server.js | 12 +
packages/strapi/src/index.js | 80 +
.../strapi/src/policies/updateOwnerOnly.js | 34 +
packages/strapi/yarn.lock | 11853 ++++++++++++++++
packages/uppy/.env.old | 30 +
packages/uppy/.env.production | 17 +
packages/uppy/.gitignore | 145 +
packages/uppy/Dockerfile | 15 +
packages/uppy/README.md | 14 +
packages/uppy/apply-backblaze-cors-rules.sh | 8 +
packages/uppy/index.js | 124 +
packages/uppy/package.json | 31 +
pnpm-lock.yaml | 6817 +++++++++
pnpm-workspace.yaml | 5 +
464 files changed, 44681 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 compose.prod.yml
create mode 100644 compose.yml
create mode 100644 packages/next/.eslintrc.json
create mode 100644 packages/next/.gitignore
create mode 100644 packages/next/.nvmrc
create mode 100644 packages/next/CHECKS
create mode 100644 packages/next/Dockerfile.old
create mode 100644 packages/next/LICENSE
create mode 100644 packages/next/README.md
create mode 100644 packages/next/app.json
create mode 100644 packages/next/app/about/page.tsx
create mode 100644 packages/next/app/api/blogs/route.ts
create mode 100644 packages/next/app/api/page.tsx
create mode 100644 packages/next/app/api/revalidate/route.ts
create mode 100644 packages/next/app/api/service.json/route.ts
create mode 100644 packages/next/app/api/v1.json/route.ts
create mode 100644 packages/next/app/blog/2021-10-29-the-story-of-futureporn/page.tsx
create mode 100644 packages/next/app/blog/page.tsx
create mode 100644 packages/next/app/components/archive-progress.tsx
create mode 100644 packages/next/app/components/auth.tsx
create mode 100644 packages/next/app/components/cal.tsx
create mode 100644 packages/next/app/components/contributors.tsx
create mode 100644 packages/next/app/components/custom-hls-player.tsx
create mode 100644 packages/next/app/components/footer.tsx
create mode 100644 packages/next/app/components/funding-goal.tsx
create mode 100644 packages/next/app/components/icons/carrd.tsx
create mode 100644 packages/next/app/components/icons/chaturbate.tsx
create mode 100644 packages/next/app/components/icons/fansly.tsx
create mode 100644 packages/next/app/components/icons/linktree.tsx
create mode 100644 packages/next/app/components/icons/onlyfans.tsx
create mode 100644 packages/next/app/components/icons/pornhub.tsx
create mode 100644 packages/next/app/components/icons/throne.tsx
create mode 100644 packages/next/app/components/ipfs-cid.tsx
create mode 100644 packages/next/app/components/ipfs-logo.tsx
create mode 100644 packages/next/app/components/ipfs.tsx
create mode 100644 packages/next/app/components/linkable-heading.tsx
create mode 100644 packages/next/app/components/localized-date.tsx
create mode 100644 packages/next/app/components/navbar.tsx
create mode 100644 packages/next/app/components/notification-center.tsx
create mode 100644 packages/next/app/components/notifications.tsx
create mode 100644 packages/next/app/components/pager.tsx
create mode 100644 packages/next/app/components/patrons-list.tsx
create mode 100644 packages/next/app/components/sortable-tags.tsx
create mode 100644 packages/next/app/components/stream-button.tsx
create mode 100644 packages/next/app/components/stream-page.tsx
create mode 100644 packages/next/app/components/stream.tsx
create mode 100644 packages/next/app/components/streams-calendar.tsx
create mode 100644 packages/next/app/components/streams-list.tsx
create mode 100644 packages/next/app/components/tag-button.tsx
create mode 100644 packages/next/app/components/tag.tsx
create mode 100644 packages/next/app/components/tagger.tsx
create mode 100644 packages/next/app/components/timestamps-list.tsx
create mode 100644 packages/next/app/components/toys.tsx
create mode 100644 packages/next/app/components/upload-form.tsx
create mode 100644 packages/next/app/components/user-controls.tsx
create mode 100644 packages/next/app/components/video-context.tsx
create mode 100644 packages/next/app/components/video-interactive.tsx
create mode 100644 packages/next/app/components/video-player.tsx
create mode 100644 packages/next/app/components/video-source-selector.tsx
create mode 100644 packages/next/app/components/vod-card.tsx
create mode 100644 packages/next/app/components/vod-nav.tsx
create mode 100644 packages/next/app/components/vod-page.tsx
create mode 100644 packages/next/app/components/vods-list.tsx
create mode 100644 packages/next/app/components/vtuber-button.tsx
create mode 100644 packages/next/app/components/vtuber-card.tsx
create mode 100644 packages/next/app/connect/patreon/redirect/page.tsx
create mode 100644 packages/next/app/faq/page.tsx
create mode 100644 packages/next/app/favicon.ico
create mode 100644 packages/next/app/feed/feed.json/route.ts
create mode 100644 packages/next/app/feed/feed.xml/route.ts
create mode 100644 packages/next/app/feed/page.tsx
create mode 100644 packages/next/app/feed/rss.xml/route.ts
create mode 100644 packages/next/app/goals/page.tsx
create mode 100644 packages/next/app/health/page.tsx
create mode 100644 packages/next/app/latest-vods/[page]/page.tsx
create mode 100644 packages/next/app/latest-vods/page.tsx
create mode 100644 packages/next/app/layout.tsx
create mode 100644 packages/next/app/lib/b2File.ts
create mode 100644 packages/next/app/lib/blog.ts
create mode 100644 packages/next/app/lib/constants.ts
create mode 100644 packages/next/app/lib/contributors.ts
create mode 100644 packages/next/app/lib/dates.ts
create mode 100644 packages/next/app/lib/fetch-api.ts
create mode 100644 packages/next/app/lib/fetchers.ts
create mode 100644 packages/next/app/lib/ipfs.ts
create mode 100644 packages/next/app/lib/patreon.ts
create mode 100644 packages/next/app/lib/pm.ts
create mode 100644 packages/next/app/lib/retry.ts
create mode 100644 packages/next/app/lib/rss.ts
create mode 100644 packages/next/app/lib/shareRef.ts
create mode 100644 packages/next/app/lib/streams.ts
create mode 100644 packages/next/app/lib/tag-vod-relations.ts
create mode 100644 packages/next/app/lib/tags.ts
create mode 100644 packages/next/app/lib/timestamps.ts
create mode 100644 packages/next/app/lib/toys.ts
create mode 100644 packages/next/app/lib/tweets.ts
create mode 100644 packages/next/app/lib/types.ts
create mode 100644 packages/next/app/lib/useForwardRef.ts
create mode 100644 packages/next/app/lib/users.ts
create mode 100644 packages/next/app/lib/vods.ts
create mode 100644 packages/next/app/lib/vtubers.ts
create mode 100644 packages/next/app/page.tsx
create mode 100644 packages/next/app/patrons/page.tsx
create mode 100644 packages/next/app/profile/page.tsx
create mode 100644 packages/next/app/streams/[cuid]/not-found.tsx
create mode 100644 packages/next/app/streams/[cuid]/page.tsx
create mode 100644 packages/next/app/streams/page.tsx
create mode 100644 packages/next/app/tags/[slug]/page.tsx
create mode 100644 packages/next/app/tags/page.tsx
create mode 100644 packages/next/app/upload/page.tsx
create mode 100644 packages/next/app/upload/page.tsx.old
create mode 100644 packages/next/app/uppy.tsx
create mode 100644 packages/next/app/vods/[safeDateOrCuid]/page.tsx
create mode 100644 packages/next/app/vods/page.tsx
create mode 100644 packages/next/app/vt/[slug]/history/page.tsx
create mode 100644 packages/next/app/vt/[slug]/not-found.tsx
create mode 100644 packages/next/app/vt/[slug]/page.tsx
create mode 100644 packages/next/app/vt/[slug]/stream/[safeDate]/page.tsx
create mode 100644 packages/next/app/vt/[slug]/streams/page.tsx
create mode 100644 packages/next/app/vt/[slug]/toys/[page]/page.tsx
create mode 100644 packages/next/app/vt/[slug]/toys/page.tsx
create mode 100644 packages/next/app/vt/[slug]/vod/[safeDateOrCuid]/page.tsx
create mode 100644 packages/next/app/vt/[slug]/vod/page.tsx
create mode 100644 packages/next/app/vt/[slug]/vods/[page]/page.tsx
create mode 100644 packages/next/app/vt/[slug]/vods/page.tsx
create mode 100644 packages/next/app/vt/page.tsx
create mode 100644 packages/next/assets/styles/calendar-heatmap.module.scss
create mode 100644 packages/next/assets/styles/cid.module.css
create mode 100644 packages/next/assets/styles/fp.module.css
create mode 100644 packages/next/assets/styles/global.sass
create mode 100644 packages/next/assets/styles/icon.module.css
create mode 100644 packages/next/assets/styles/player.module.css
create mode 100644 packages/next/assets/svg/README.md
create mode 100644 packages/next/assets/svg/carrd.svg
create mode 100644 packages/next/assets/svg/chaturbate.svg
create mode 100644 packages/next/assets/svg/checkmark.svg
create mode 100644 packages/next/assets/svg/fansly.tsx
create mode 100644 packages/next/assets/svg/ipfs.svg
create mode 100644 packages/next/assets/svg/linktree.svg
create mode 100644 packages/next/assets/svg/noun-adult-content-1731184.svg
create mode 100644 packages/next/assets/svg/noun-anime-3890912.svg
create mode 100644 packages/next/assets/svg/noun-avatar-3546974.svg
create mode 100644 packages/next/assets/svg/noun-girl-842331.svg
create mode 100644 packages/next/assets/svg/noun-network-1603820.svg
create mode 100644 packages/next/assets/svg/onlyfans.svg
create mode 100644 packages/next/assets/svg/pornhub.svg
create mode 100644 packages/next/assets/svg/throne.svg
create mode 100644 packages/next/next.config.js
create mode 100644 packages/next/package.json
create mode 100644 packages/next/public/futureporn-icon.png
create mode 100644 packages/next/public/images/.keep
create mode 100644 packages/next/public/images/cj_clippy.jpg
create mode 100644 packages/next/public/images/default-thumbnail.webp
create mode 100644 packages/next/public/images/projekt-melody.jpg
create mode 100644 packages/next/public/images/projektmelody-thumbnail.webp
create mode 100644 packages/next/public/images/vercel.svg
create mode 100644 packages/next/tsconfig.json
create mode 100644 packages/strapi/.dockerignore
create mode 100644 packages/strapi/.gitignore
create mode 100644 packages/strapi/.nvmrc
create mode 100644 packages/strapi/.strapi/client/app.js
create mode 100644 packages/strapi/.strapi/client/index.html
create mode 100644 packages/strapi/Dockerfile
create mode 100644 packages/strapi/README.md
create mode 100644 packages/strapi/backup/Dockerfile.1704607848934
create mode 100644 packages/strapi/chisel.sh
create mode 100644 packages/strapi/config/admin.js
create mode 100644 packages/strapi/config/api.js
create mode 100644 packages/strapi/config/database.js
create mode 100644 packages/strapi/config/middlewares.js
create mode 100644 packages/strapi/config/plugins.js
create mode 100644 packages/strapi/config/server.js
create mode 100644 packages/strapi/database/daily-backup.sh
create mode 100755 packages/strapi/database/devDb.sh
create mode 100644 packages/strapi/database/migrations/.gitkeep
create mode 100644 packages/strapi/database/migrations/2023-08-01-relate-vods-to-vtubers-part2.js
create mode 100644 packages/strapi/database/migrations/2023-08-17-reformat-cdnUrl.js
create mode 100644 packages/strapi/database/migrations/2023-08-20-strip-query-string-from-cid.js
create mode 100644 packages/strapi/database/migrations/2023-08-30-remove-cloudinary.js
create mode 100644 packages/strapi/database/migrations/2023-08-30-toy-image-field-simplify.js
create mode 100644 packages/strapi/database/migrations/2023-09-08-change-date-to-string.js
create mode 100644 packages/strapi/database/migrations/2023-09-08-drop-toys-image.js
create mode 100644 packages/strapi/database/migrations/2023-09-08-drop-vod-videosrc.js
create mode 100644 packages/strapi/database/migrations/2023-12-24-add-cuid-to-vods.js
create mode 100644 packages/strapi/database/migrations/2023-12-26-add-cuid-to-streams.js
create mode 100644 packages/strapi/database/migrations/2023-12-27-relate-vods-to-streams.js
create mode 100644 packages/strapi/database/migrations/2023.05.09-video-src-sanity.js.noexec
create mode 100644 packages/strapi/database/migrations/2023.05.11T12.32.00.convert-to-video-src-b2.js.noexec
create mode 100644 packages/strapi/database/migrations/2023.05.14T00.42.00.000Z.migrate-tags-to-tag-vod-relations.js
create mode 100644 packages/strapi/database/migrations/2023.05.15T02.44.00.000Z.drop-vod-tags.js
create mode 100644 packages/strapi/database/migrations/2023.05.25-gimme-the-tags.js.noexec
create mode 100644 packages/strapi/database/migrations/2023.05.25T20.44.00.000Z.get-the-og-tags.js
create mode 100644 packages/strapi/database/migrations/2023.07.17.relate-vods-to-vtubers.js
create mode 100644 packages/strapi/database/migrations/2023.07.31.add-b2-file-cdnUrl.js
create mode 100644 packages/strapi/database/migrations/2024-01-08-add-streams.js.noexec
create mode 100644 packages/strapi/database/migrations/2024-01-14-add-date2-to-streams.js
create mode 100644 packages/strapi/database/migrations/2024-01-15-add-platform-to-streams.js
create mode 100644 packages/strapi/database/og-tags.json
create mode 100644 packages/strapi/favicon.png
create mode 100644 packages/strapi/misc/2023-05-26-export-og-tags.js
create mode 100644 packages/strapi/misc/generateCuid.js
create mode 100644 packages/strapi/package.json
create mode 100644 packages/strapi/public/robots.txt
create mode 100644 packages/strapi/public/uploads/.gitkeep
create mode 100644 packages/strapi/src/admin/app.example.js
create mode 100644 packages/strapi/src/admin/webpack.config.example.js
create mode 100644 packages/strapi/src/api/.gitkeep
create mode 100644 packages/strapi/src/api/b2-file/content-types/b2-file/schema.json
create mode 100644 packages/strapi/src/api/b2-file/controllers/b2-file.js
create mode 100644 packages/strapi/src/api/b2-file/routes/b2-file.js
create mode 100644 packages/strapi/src/api/b2-file/services/b2-file.js
create mode 100644 packages/strapi/src/api/contributor/content-types/contributor/schema.json
create mode 100644 packages/strapi/src/api/contributor/controllers/contributor.js
create mode 100644 packages/strapi/src/api/contributor/routes/contributor.js
create mode 100644 packages/strapi/src/api/contributor/services/contributor.js
create mode 100644 packages/strapi/src/api/goal/content-types/goal/schema.json
create mode 100644 packages/strapi/src/api/goal/controllers/goal.js
create mode 100644 packages/strapi/src/api/goal/routes/goal.js
create mode 100644 packages/strapi/src/api/goal/services/goal.js
create mode 100644 packages/strapi/src/api/gogs/content-types/gogs/schema.json
create mode 100644 packages/strapi/src/api/gogs/controllers/gogs.js
create mode 100644 packages/strapi/src/api/gogs/routes/gogs.js
create mode 100644 packages/strapi/src/api/gogs/services/gogs.js
create mode 100644 packages/strapi/src/api/issue/content-types/issue/schema.json
create mode 100644 packages/strapi/src/api/issue/controllers/issue.js
create mode 100644 packages/strapi/src/api/issue/routes/issue.js
create mode 100644 packages/strapi/src/api/issue/services/issue.js
create mode 100644 packages/strapi/src/api/mux-asset/content-types/mux-asset/schema.json
create mode 100644 packages/strapi/src/api/mux-asset/controllers/mux-asset.js
create mode 100644 packages/strapi/src/api/mux-asset/routes/mux-asset.js
create mode 100644 packages/strapi/src/api/mux-asset/services/mux-asset.js
create mode 100644 packages/strapi/src/api/patreon/content-types/patreon/schema.json
create mode 100644 packages/strapi/src/api/patreon/controllers/patreon.js
create mode 100644 packages/strapi/src/api/patreon/routes/patreon.js
create mode 100644 packages/strapi/src/api/patreon/services/patreon.js
create mode 100644 packages/strapi/src/api/profile/controllers/profile.js
create mode 100644 packages/strapi/src/api/profile/routes/profile.js
create mode 100644 packages/strapi/src/api/profile/services/profile.js
create mode 100644 packages/strapi/src/api/stream/content-types/stream/lifecycles.js
create mode 100644 packages/strapi/src/api/stream/content-types/stream/schema.json
create mode 100644 packages/strapi/src/api/stream/controllers/stream.js
create mode 100644 packages/strapi/src/api/stream/routes/stream.js
create mode 100644 packages/strapi/src/api/stream/services/stream.js
create mode 100644 packages/strapi/src/api/tag-vod-relation/content-types/tag-vod-relation/schema.json
create mode 100644 packages/strapi/src/api/tag-vod-relation/controllers/tag-vod-relation.js
create mode 100644 packages/strapi/src/api/tag-vod-relation/routes/tag-vod-relation.js
create mode 100644 packages/strapi/src/api/tag-vod-relation/services/tag-vod-relation.js
create mode 100644 packages/strapi/src/api/tag/content-types/tag/schema.json
create mode 100644 packages/strapi/src/api/tag/controllers/tag.js
create mode 100644 packages/strapi/src/api/tag/routes/tag.js
create mode 100644 packages/strapi/src/api/tag/services/tag.js
create mode 100644 packages/strapi/src/api/timestamp/content-types/timestamp/schema.json
create mode 100644 packages/strapi/src/api/timestamp/controllers/timestamp.js
create mode 100644 packages/strapi/src/api/timestamp/routes/timestamp.js
create mode 100644 packages/strapi/src/api/timestamp/services/timestamp.js
create mode 100644 packages/strapi/src/api/toy/content-types/toy/schema.json
create mode 100644 packages/strapi/src/api/toy/controllers/toy.js
create mode 100644 packages/strapi/src/api/toy/routes/toy.js
create mode 100644 packages/strapi/src/api/toy/services/toy.js
create mode 100644 packages/strapi/src/api/tweet/content-types/tweet/lifecycles.js
create mode 100644 packages/strapi/src/api/tweet/content-types/tweet/schema.json
create mode 100644 packages/strapi/src/api/tweet/controllers/tweet.js
create mode 100644 packages/strapi/src/api/tweet/routes/tweet.js
create mode 100644 packages/strapi/src/api/tweet/services/tweet.js
create mode 100644 packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/lifecycles.js
create mode 100644 packages/strapi/src/api/user-submitted-content/content-types/user-submitted-content/schema.json
create mode 100644 packages/strapi/src/api/user-submitted-content/controllers/user-submitted-content.js
create mode 100644 packages/strapi/src/api/user-submitted-content/routes/user-submitted-content.js
create mode 100644 packages/strapi/src/api/user-submitted-content/services/user-submitted-content.js
create mode 100644 packages/strapi/src/api/vod/content-types/lifecycles.js
create mode 100644 packages/strapi/src/api/vod/content-types/vod/schema.json
create mode 100644 packages/strapi/src/api/vod/controllers/vod.js
create mode 100644 packages/strapi/src/api/vod/routes/vod.js
create mode 100644 packages/strapi/src/api/vod/services/vod.js
create mode 100644 packages/strapi/src/api/vtuber/content-types/vtuber/lifecycles.js
create mode 100644 packages/strapi/src/api/vtuber/content-types/vtuber/schema.json
create mode 100644 packages/strapi/src/api/vtuber/controllers/vtuber.js
create mode 100644 packages/strapi/src/api/vtuber/routes/vtuber.js
create mode 100644 packages/strapi/src/api/vtuber/services/vtuber.js
create mode 100644 packages/strapi/src/extensions/.gitkeep
create mode 100644 packages/strapi/src/extensions/users-permissions/.eslintignore
create mode 100644 packages/strapi/src/extensions/users-permissions/.eslintrc.js
create mode 100644 packages/strapi/src/extensions/users-permissions/LICENSE
create mode 100644 packages/strapi/src/extensions/users-permissions/README.md
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/getMethodColor.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/BoundRoute/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/Input/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/FormModal/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/CheckboxWrapper.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/SubCategory.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/PermissionRow/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/init.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Permissions/reducer.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/Policies/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/init.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/components/UsersPermissions/reducer.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/contexts/UsersPermissionsContext/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useFetchRole/reducer.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useForm/reducer.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/usePlugins.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/init.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/hooks/useRolesList/reducer.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/api.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/layout.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/AdvancedSettings/utils/schema.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/components/EmailForm.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/components/EmailTable.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/utils/api.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/EmailTemplates/utils/schema.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Providers/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Providers/reducer.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Providers/utils/api.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Providers/utils/createProvidersArray.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Providers/utils/forms.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/CreatePage.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/EditPage.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/ListPage/components/TableBody.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/ListPage/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/ListPage/utils/api.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/ProtectedCreatePage.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/ProtectedEditPage.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/ProtectedListPage.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/constants.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pages/Roles/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/permissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/pluginId.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/ar.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/cs.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/de.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/dk.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/en.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/es.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/fr.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/id.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/it.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/ja.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/ko.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/ms.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/nl.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/pl.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/pt-BR.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/pt.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/ru.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/sk.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/sv.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/th.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/tr.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/uk.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/vi.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/zh-Hans.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/translations/zh.json
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/utils/cleanPermissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/utils/formatPluginName.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/utils/formatPolicies.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/utils/getRequestURL.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/utils/getTrad.js
create mode 100644 packages/strapi/src/extensions/users-permissions/admin/src/utils/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/content-types/user/schema.json
create mode 100644 packages/strapi/src/extensions/users-permissions/documentation/content-api.yaml
create mode 100644 packages/strapi/src/extensions/users-permissions/jest.config.front.js
create mode 100644 packages/strapi/src/extensions/users-permissions/package.json
create mode 100644 packages/strapi/src/extensions/users-permissions/server/bootstrap/grant-config.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/bootstrap/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/bootstrap/users-permissions-actions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/config.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/content-types/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/content-types/permission/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/content-types/role/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/content-types/user/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/content-types/user/schema-config.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/auth.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/content-manager-user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/permissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/settings.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/validation/auth.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/validation/email-template.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/controllers/validation/user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/auth/change-password.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/auth/email-confirmation.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/auth/forgot-password.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/auth/login.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/auth/register.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/auth/reset-password.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/crud/role/create-role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/crud/role/delete-role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/crud/role/update-role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/crud/user/create-user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/crud/user/delete-user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/crud/user/update-user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/mutations/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/queries/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/queries/me.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/resolvers-configs.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/create-role-payload.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/delete-role-payload.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/login-input.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/login-payload.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/me-role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/me.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/password-payload.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/register-input.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/types/update-role-payload.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/graphql/utils.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/middlewares/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/middlewares/rateLimit.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/register.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/admin/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/admin/permissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/admin/role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/admin/settings.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/content-api/auth.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/content-api/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/content-api/permissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/content-api/role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/content-api/user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/routes/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/jwt.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/permission.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/providers-registry.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/providers.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/role.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/user.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/services/users-permissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/strategies/users-permissions.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/utils/index.d.ts
create mode 100644 packages/strapi/src/extensions/users-permissions/server/utils/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/utils/sanitize/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/utils/sanitize/sanitizers.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/utils/sanitize/visitors/index.js
create mode 100644 packages/strapi/src/extensions/users-permissions/server/utils/sanitize/visitors/remove-user-relation-from-role-entities.js
create mode 100644 packages/strapi/src/extensions/users-permissions/strapi-admin.js
create mode 100644 packages/strapi/src/extensions/users-permissions/strapi-server.js
create mode 100644 packages/strapi/src/index.js
create mode 100644 packages/strapi/src/policies/updateOwnerOnly.js
create mode 100644 packages/strapi/yarn.lock
create mode 100644 packages/uppy/.env.old
create mode 100644 packages/uppy/.env.production
create mode 100644 packages/uppy/.gitignore
create mode 100644 packages/uppy/Dockerfile
create mode 100644 packages/uppy/README.md
create mode 100755 packages/uppy/apply-backblaze-cors-rules.sh
create mode 100644 packages/uppy/index.js
create mode 100644 packages/uppy/package.json
create mode 100644 pnpm-lock.yaml
create mode 100644 pnpm-workspace.yaml
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
+
+
+
+
+
+
+
+
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) => (
+
+ ))}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ 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 (
+ {
+ login();
+ }}
+ >
+
+
+
+ Login
+
+ );
+}
+
+export function LogoutButton() {
+ const context = useContext(AuthContext);
+ if (!context) return <>>;
+ const { logout } = context;
+ return (
+ {
+ logout();
+ }}
+ >
+
+
+
+ Logout
+
+ );
+}
+
+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
+
+
+
+
+
+
+
+
+ {/* 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 (
+ <>
+ {isoDate}
+ >
+ )
+}
\ 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 (
+ <>
+
+
+
+
🔞💦 Futureporn.net
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ 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 (
+
+
+ {page > 1 && (
+
+ Previous
+
+ )}
+ {page < pageCount && (
+
+ Next
+
+ )}
+
+
+ {startPage > 1 && (
+
+
+ 1
+
+
+ )}
+
+ {startPage > 2 && (
+
+ …
+
+ )}
+
+ {pageNumbers.slice(startPage - 1, endPage).map((pageNumber) => (
+
+
+
+ {pageNumber}
+
+
+
+ ))}
+
+ {endPage < pageCount - 1 && (
+
+ …
+
+ )}
+
+ {endPage !== pageCount && (
+
+
+
+ {pageCount}
+
+
+
+ )}
+
+
+
+ );
+}
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)}
+ />
+
+
+
+
+
+
+ setSortOption(e.target.value)}
+ >
+ Sort
+ Alphabetical
+ Frequency
+
+
+
+
+
+ {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 {date.toISOString()}
+ Local Datetime {date.toLocaleDateString()} {date.toLocaleTimeString()}
+ Lunar Phase {Moon.lunarPhase(date)} {Moon.lunarPhaseEmoji(date, { hemisphere })}
+
+ {/* setSelectedStatus(e.target.value as Status)}
+ >
+ good
+ issue
+ missing
+ */}
+
+
+
+
+
+
+
+
+
+ VOD {capitalizeFirstLetter(selectedStatus)}
+
+
+
+
{desc1}
+
{desc2}
+ Upload it here.
+
+
+
+
+
+
+
+
+
VODs
+
+
+
+ ID
+ Upload Date
+ {/* Thumbnail
+ Duration */}
+ Tags
+ Timestamps
+ Note
+
+
+
+ {stream.attributes.vods.data.map((vod: IVod) => (
+
+ {/* {JSON.stringify(vod, null, 2)}
*/}
+ {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.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
+
+
+ {streams.length < 1 && There are no streams
}
+ {streams.map((stream: IStream) => (
+
+
+
+ ))}
+
+
+ >
+ );
+}
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 (
+ (selectedTag === name) ? setSelectedTag('') : setSelectedTag(name)} className={`button is-small mr-2 mb-1 ${(selectedTag === name) ? 'is-info' : ''}`}>{name}
+ )
+}
\ 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
+ {
+ setIsEditor(false);
+ setValue('tagName', '');
+ setTagSuggestions([]);
+ clearErrors();
+ }} className='card-header-icon'>
+
+
+
+
+
+
+
+ )
+ } else {
+ return (
+ setIsEditor(true)}>
+
+ Add a Tag
+
+ );
+ }
+ }
+
+
+}
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) && (
+ {
+ if (!authContext?.authData) return;
+ deleteTimestamp(authContext.authData, ts.id);
+ setTimestamps((prevTimestamps: ITimestamp[]) => prevTimestamps.filter((timestamp) => timestamp.id !== ts.id));
+ }}
+ className={`button icon`}
+ >
+
+
+ )}
+
+ ))
+ )}
+
+ {!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 (
+
+
+
+
+
+
+
{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)
+ ?
+ <>
+
+
+ >
+ : (
+
+
+
+
+
+
+ )
+ }
+
+
+
+ >
+ )
+
+}
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 (
+
+
+
+
+
+
+
+
+
+
+ Save
+
+ )
+}
+
+export function Thanks() {
+ return Thank you so much for supporting Futureporn!
+}
+
+export function QuantumSupporterPerks({ isLinkPublic, setIsLinkPublic, setVanityLink, vanityLink, hasUrlBenefit }: IQuantumSupporterProps) {
+ const { authData } = useAuth()
+
+ return (
+
+
URL
+
+
+ setIsLinkPublic(!isLinkPublic)}
+ />
+ Publicly display my URL {vanityLink} on the patrons page.
+
+
+
+ setVanityLink(e.target.value)}
+ />
+
+
+
+ )
+}
+
+export function AdvancedArchiveSupporterPerks() {
+
+}
+
+export function ArchiveSupporterPerks({ isNamePublic, setIsNamePublic }: IArchiveSupporterProps) {
+ const { authData } = useAuth()
+
+ return (
+
+
Username
+
+
+ setIsNamePublic(!isNamePublic)}
+ />
+ Publicly display {(authData?.user?.username) ? authData.user.username : } on the patrons page.
+
+
+
+ )
+}
\ 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 &&
}
+ {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 (
+ <>
+
+
+
+ Video Source Selector
+
+ {(!isMux && !isB2 && !isIPFSSource && !isIPFS240) &&
+
+ No video sources available
+
+
}
+ {(isMux) &&
+ handleSourceClick('Mux')} disabled={!isEntitledToCDN} className={`button ${selectedVideoSource === 'Mux' && 'is-active'}`}>
+
+
+
+ CDN 1
+
+
}
+ {(isB2) &&
+ handleSourceClick('B2')} disabled={!isEntitledToCDN} className={`button ${selectedVideoSource === 'B2' && 'is-active'}`}>
+
+
+
+ CDN 2
+
+
}
+ {(isIPFSSource) &&
+ handleSourceClick('IPFSSource')} className={`button ${(selectedVideoSource === 'IPFSSource') && 'is-active'}`}>
+
+
+
+ IPFS Src
+
+
}
+ {(isIPFS240) &&
+ handleSourceClick('IPFS240')} className={`button ${(selectedVideoSource === 'IPFS240') && 'is-active'}`}>
+
+
+
+ IPFS 240p
+
+
}
+
+
+ >
+ )
+}
\ 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}
+
+
+
+
+
+
+ {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 (
+
+
+
+ {vod.attributes.videoSrcHash && (
+ <>
+
+
+
+ Source
+
+
+
+ >
+ )}
+ {vod.attributes.video240Hash && (
+
+
+
+
+ 240p
+
+
+
+
+ )}
+ {vod.attributes.announceUrl && (
+
+
+
+
+
+ )}
+
+
+ )
+}
\ 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 && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+ {!!previousVod && (
+
+
+ Prev VOD {getLocalizedDate(previousVod)}
+
+ )}
+
+
+
+
+
UID {vod.attributes.cuid}
+
+
+
+
+ {!!nextVod && (
+
+ Next VOD {getLocalizedDate(nextVod)}
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
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}
+
+ );
+}
\ 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 (
+
+
+
+ )
+ }
\ 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 0000000000000000000000000000000000000000..2ed11c7480c90fcd2c32df0465864598baa02c05
GIT binary patch
literal 318
zcmbV|I}U&_3_~4>12_v}3uBMTy&1qML
+
+
+
+
+
RSS Feed
+
+
Keep up to date with new VODs using Real Simple Syndication (RSS).
+
+
Don't have a RSS reader? Futureporn recommends Fraidycat
+
+
+
+
+
+ >
+ )
+}
+
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 (
+
+ )
+}
\ 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
+ {/*
+ {vtubers.data.map((vtuber: IVtuber) =>
+
+ )}
+ */}
+
+
+ >
+ );
+}
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) => (
+
+ ))}
+
+
+
+
+ )
+}
\ 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 (
+
+ )
+}
\ 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
+ ? Failed to fetch vtubers list. Please try again later.
+ :
+ }
+
+
+ >
+ )
+}
\ 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.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 && (
+
+ )}
+ {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
+
+ {vtubers.data.map((vtuber: IVtuber) =>
+
+ )}
+
+
+
+ >
+ )
+}
\ 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 Babii from 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 Kevin from 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 2 Created by KEN111 from 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 Cloe from 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 Five from 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 0000000000000000000000000000000000000000..037e639e49e8b4ebe1835af95607fdeaa0a8cfcc
GIT binary patch
literal 11599
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
N002ovPDHLkV1jjAs`dZ?
literal 0
HcmV?d00001
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 0000000000000000000000000000000000000000..d1c871709a662ad970baff230eeb0aaac0d0033d
GIT binary patch
literal 301528
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