diff --git a/README.md b/README.md index 654b9b9355..5754915c7a 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ You can also open an issue on GitHub if you find any bugs or have feature reques and fitness - ✅ Import data from Goodreads, Trakt, Strong App [etc](https://docs.ryot.io/importing.html) - ✅ Integration with Jellyfin, Kodi, Plex, Emby, Audiobookshelf [etc](https://docs.ryot.io/integrations.html) -- ✅ [Supports](https://docs.ryot.io/guides/openid.html) OpenID Connect +- ✅ [Supports](https://docs.ryot.io/guides/authentication.html) OpenID Connect - ✅ Sends notifications to Discord, Ntfy, Apprise etc - ✅ Self-hosted - ✅ PWA enabled diff --git a/apps/frontend/app/lib/generals.ts b/apps/frontend/app/lib/generals.ts index 41100a1a73..b9ee300f3f 100644 --- a/apps/frontend/app/lib/generals.ts +++ b/apps/frontend/app/lib/generals.ts @@ -66,12 +66,11 @@ export const emptyDecimalString = z .transform((v) => (!v ? undefined : Number.parseFloat(v).toString())) .nullable(); -export const CurrentWorkoutKey = "CurrentWorkout"; export const LOGO_IMAGE_URL = "https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png"; export const redirectToQueryParam = "redirectTo"; export const pageQueryParam = "page"; -export const AUTH_COOKIE_NAME = "Auth"; +export const FRONTEND_AUTH_COOKIE_NAME = "Auth"; export const toastKey = "Toast"; export const PRO_REQUIRED_MESSAGE = "Ryot pro is required to use this feature"; @@ -316,7 +315,7 @@ export const clientGqlService = new GraphQLClient( `${applicationBaseUrl}/backend/graphql`, { headers: () => { - const data = Cookies.get(AUTH_COOKIE_NAME); + const data = Cookies.get(FRONTEND_AUTH_COOKIE_NAME); return { authorization: data ? `Bearer ${data}` : "" }; }, }, diff --git a/apps/frontend/app/lib/hooks.ts b/apps/frontend/app/lib/hooks.ts index 573908919b..0d92d1b172 100644 --- a/apps/frontend/app/lib/hooks.ts +++ b/apps/frontend/app/lib/hooks.ts @@ -18,8 +18,7 @@ import { $path } from "remix-routes"; import invariant from "tiny-invariant"; import { useInterval } from "usehooks-ts"; import { - CurrentWorkoutKey, - type FitnessAction, + FitnessAction, dayjsLib, getMetadataDetailsQuery, getStringAsciiValue, @@ -104,10 +103,6 @@ export const useGetWorkoutStarter = () => { const fn = (wkt: InProgressWorkout, action: FitnessAction) => { setCurrentWorkout(wkt); - Cookies.set(CurrentWorkoutKey, action, { - expires: 2, - sameSite: "Strict", - }); navigate($path("/fitness/:action", { action })); revalidator.revalidate(); }; @@ -208,3 +203,13 @@ export const useComplexJsonUpdate = () => { return { reset, appendPref, toUpdatePreferences }; }; + +export const useIsFitnessActionActive = () => { + const [currentWorkout] = useCurrentWorkout(); + const action = currentWorkout?.currentActionOrCompleted; + return ( + action !== undefined && + action !== true && + [FitnessAction.LogWorkout, FitnessAction.CreateTemplate].includes(action) + ); +}; diff --git a/apps/frontend/app/lib/state/fitness.ts b/apps/frontend/app/lib/state/fitness.ts index a98e404839..3deae901ab 100644 --- a/apps/frontend/app/lib/state/fitness.ts +++ b/apps/frontend/app/lib/state/fitness.ts @@ -20,13 +20,12 @@ import { queryOptions } from "@tanstack/react-query"; import { createDraft, finishDraft } from "immer"; import { atom, useAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; -import Cookies from "js-cookie"; import { $path } from "remix-routes"; import { match } from "ts-pattern"; import { withFragment } from "ufo"; import { v4 as randomUUID } from "uuid"; import { - CurrentWorkoutKey, + type FitnessAction, clientGqlService, dayjsLib, getTimeOfDay, @@ -80,12 +79,13 @@ export type InProgressWorkout = { exercises: Array; replacingExerciseIdx?: number; updateWorkoutTemplateId?: string; + currentActionOrCompleted: FitnessAction | true; }; type CurrentWorkout = InProgressWorkout | null; const currentWorkoutAtom = atomWithStorage( - CurrentWorkoutKey, + "CurrentWorkout", null, ); @@ -101,13 +101,16 @@ export const useGetSetAtIndex = (exerciseIdx: number, setIdx: number) => { return exercise?.sets[setIdx]; }; -export const getDefaultWorkout = (): InProgressWorkout => { +export const getDefaultWorkout = ( + fitnessEntity: FitnessAction, +): InProgressWorkout => { const date = new Date(); return { images: [], videos: [], supersets: [], exercises: [], + currentActionOrCompleted: fitnessEntity, startTime: date.toISOString(), name: `${getTimeOfDay(date.getHours())} Workout`, }; @@ -259,6 +262,7 @@ export const useMeasurementsDrawerOpen = () => export const duplicateOldWorkout = async ( name: string, + fitnessEntity: FitnessAction, workoutInformation: WorkoutInformation, coreDetails: ReturnType, userFitnessPreferences: UserFitnessPreferences, @@ -269,7 +273,7 @@ export const duplicateOldWorkout = async ( updateWorkoutTemplateId?: string; }, ) => { - const inProgress = getDefaultWorkout(); + const inProgress = getDefaultWorkout(fitnessEntity); inProgress.name = name; inProgress.repeatedFrom = params.repeatedFromId; inProgress.templateId = params.templateId; @@ -345,6 +349,7 @@ export const addExerciseToWorkout = async ( setCurrentWorkout: (v: InProgressWorkout) => void, selectedExercises: Array<{ name: string; lot: ExerciseLot }>, ) => { + if (currentWorkout.currentActionOrCompleted === true) return; const draft = createDraft(currentWorkout); const idxOfNextExercise = draft.exercises.length; for (const [_exerciseIdx, ex] of selectedExercises.entries()) { @@ -390,11 +395,11 @@ export const addExerciseToWorkout = async ( } const finishedDraft = finishDraft(draft); setCurrentWorkout(finishedDraft); - const currentEntity = Cookies.get(CurrentWorkoutKey); - if (!currentEntity) return; navigate( withFragment( - $path("/fitness/:action", { action: currentEntity }), + $path("/fitness/:action", { + action: currentWorkout.currentActionOrCompleted, + }), idxOfNextExercise.toString(), ), ); diff --git a/apps/frontend/app/lib/utilities.server.ts b/apps/frontend/app/lib/utilities.server.ts index 0fec7af1a8..deafe350d2 100644 --- a/apps/frontend/app/lib/utilities.server.ts +++ b/apps/frontend/app/lib/utilities.server.ts @@ -23,6 +23,7 @@ import { type RequestDocument, type Variables, } from "graphql-request"; +import { jwtDecode } from "jwt-decode"; import type { VariablesAndRequestHeadersArgs } from "node_modules/graphql-request/build/legacy/helpers/types"; import { $path } from "remix-routes"; import { match } from "ts-pattern"; @@ -30,9 +31,7 @@ import { withoutHost } from "ufo"; import { v4 as randomUUID } from "uuid"; import { z } from "zod"; import { - AUTH_COOKIE_NAME, - CurrentWorkoutKey, - FitnessAction, + FRONTEND_AUTH_COOKIE_NAME, dayjsLib, emptyDecimalString, emptyNumberString, @@ -103,7 +102,7 @@ export const getCookieValue = (request: Request, cookieName: string) => parse(request.headers.get("cookie") || "")[cookieName]; export const getAuthorizationCookie = (request: Request) => - getCookieValue(request, AUTH_COOKIE_NAME); + getCookieValue(request, FRONTEND_AUTH_COOKIE_NAME); export const redirectIfNotAuthenticatedOrUpdated = async (request: Request) => { const userDetails = await getCachedUserDetails(request); @@ -156,6 +155,14 @@ export const MetadataSpecificsSchema = z.object({ mangaVolumeNumber: emptyNumberString, }); +export const getDecodedJwt = (request: Request) => { + const token = getAuthorizationCookie(request) ?? ""; + return jwtDecode<{ + sub: string; + access_link?: { id: string; is_demo?: boolean }; + }>(token); +}; + export const getCachedCoreDetails = async () => { return await queryClient.ensureQueryData({ queryKey: queryFactory.miscellaneous.coreDetails().queryKey, @@ -165,9 +172,9 @@ export const getCachedCoreDetails = async () => { }; const getCachedUserDetails = async (request: Request) => { - const token = getAuthorizationCookie(request); + const decodedJwt = getDecodedJwt(request); return await queryClient.ensureQueryData({ - queryKey: queryFactory.users.details(token ?? "").queryKey, + queryKey: queryFactory.users.details(decodedJwt.sub).queryKey, queryFn: () => serverGqlService .authenticatedRequest(request, UserDetailsDocument, undefined) @@ -349,13 +356,15 @@ export const getCookiesForApplication = async ( (tokenValidForDays || coreDetails.tokenValidForDays) * 24 * 60 * 60; const options = { maxAge, path: "/" } satisfies SerializeOptions; return combineHeaders({ - "set-cookie": serialize(AUTH_COOKIE_NAME, token, options), + "set-cookie": serialize(FRONTEND_AUTH_COOKIE_NAME, token, options), }); }; export const getLogoutCookies = () => { return combineHeaders({ - "set-cookie": serialize(AUTH_COOKIE_NAME, "", { expires: new Date(0) }), + "set-cookie": serialize(FRONTEND_AUTH_COOKIE_NAME, "", { + expires: new Date(0), + }), }); }; @@ -367,20 +376,6 @@ export const extendResponseHeaders = ( responseHeaders.append(key, value); }; -export const getWorkoutCookieValue = (request: Request) => { - return parse(request.headers.get("cookie") || "")[CurrentWorkoutKey]; -}; - -export const isWorkoutActive = (request: Request) => { - const cookieValue = getWorkoutCookieValue(request); - const inProgress = - cookieValue && - [FitnessAction.LogWorkout, FitnessAction.UpdateWorkout] - .map(String) - .includes(cookieValue); - return inProgress; -}; - export const getEnhancedCookieName = async (path: string, request: Request) => { const userDetails = await redirectIfNotAuthenticatedOrUpdated(request); return `SearchParams__${userDetails.id}__${path}`; diff --git a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx index 40cd9728a2..8411937d65 100644 --- a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx +++ b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx @@ -86,12 +86,12 @@ import { IconReorder, IconReplace, IconTrash, + IconZzz, } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { Howl } from "howler"; import { produce } from "immer"; import { RESET } from "jotai/utils"; -import Cookies from "js-cookie"; import { useEffect, useMemo, useRef, useState } from "react"; import Webcam from "react-webcam"; import { $path } from "remix-routes"; @@ -109,7 +109,6 @@ import { displayWeightWithUnit, } from "~/components/fitness"; import { - CurrentWorkoutKey, FitnessAction, FitnessEntity, PRO_REQUIRED_MESSAGE, @@ -146,25 +145,11 @@ import { useMeasurementsDrawerOpen, useTimerAtom, } from "~/lib/state/fitness"; -import { isWorkoutActive, redirectWithToast } from "~/lib/utilities.server"; -const workoutCookieName = CurrentWorkoutKey; - -export const loader = async ({ params, request }: LoaderFunctionArgs) => { +export const loader = async ({ params }: LoaderFunctionArgs) => { const { action } = zx.parseParams(params, { action: z.nativeEnum(FitnessAction), }); - await match(action) - .with(FitnessAction.LogWorkout, FitnessAction.UpdateWorkout, async () => { - const inProgress = isWorkoutActive(request); - if (!inProgress) - throw await redirectWithToast($path("/"), { - type: "error", - message: "No workout in progress", - }); - }) - .with(FitnessAction.CreateTemplate, async () => {}) - .exhaustive(); return { action, isUpdatingWorkout: action === FitnessAction.UpdateWorkout, @@ -427,6 +412,11 @@ export default function Page() { }); return; } + setCurrentWorkout( + produce(currentWorkout, (draft) => { + draft.currentActionOrCompleted = true; + }), + ); const yes = await confirmWrapper({ confirmation: loaderData.isCreatingTemplate ? "Only sets that have data will added. Are you sure you want to save this template?" @@ -472,11 +462,6 @@ export default function Page() { ]), ) .exhaustive(); - notifications.show({ - color: "green", - message: "Saved successfully", - }); - Cookies.remove(workoutCookieName); revalidator.revalidate(); if (loaderData.action === FitnessAction.LogWorkout) events.createWorkout(); @@ -515,7 +500,6 @@ export default function Page() { } navigate($path("/"), { replace: true }); revalidator.revalidate(); - Cookies.remove(workoutCookieName); setCurrentWorkout(RESET); } }} @@ -1634,6 +1618,27 @@ const SetDisplay = (props: { > {!set.note ? "Add" : "Remove"} note + } + onClick={() => { + setCurrentWorkout( + produce(currentWorkout, (draft) => { + const hasRestTimer = !!set.restTimer; + if (hasRestTimer) + draft.exercises[props.exerciseIdx].sets[ + props.setIdx + ].restTimer = undefined; + else + draft.exercises[props.exerciseIdx].sets[ + props.setIdx + ].restTimer = { duration: 60 }; + }), + ); + }} + > + {!set.restTimer ? "Add" : "Remove"} timer + FitnessAction.LogWorkout) - .with( - FitnessEntity.Templates, - () => FitnessAction.CreateTemplate, - ) - .exhaustive(), - ); + const action = match(loaderData.entity) + .with(FitnessEntity.Workouts, () => FitnessAction.LogWorkout) + .with( + FitnessEntity.Templates, + () => FitnessAction.CreateTemplate, + ) + .exhaustive(); + startWorkout(getDefaultWorkout(action), action); }} > diff --git a/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx index 7d73e0799b..414c0f5461 100644 --- a/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx +++ b/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx @@ -82,6 +82,7 @@ import { } from "~/lib/generals"; import { useComplexJsonUpdate, + useIsFitnessActionActive, useUserDetails, useUserPreferences, useUserUnitSystem, @@ -95,7 +96,6 @@ import { useAddEntityToCollection, useReviewEntity } from "~/lib/state/media"; import { createToastHeaders, getCachedExerciseParameters, - getWorkoutCookieValue, serverGqlService, } from "~/lib/utilities.server"; @@ -110,7 +110,6 @@ const paramsSchema = { id: z.string() }; export const loader = async ({ params, request }: LoaderFunctionArgs) => { const { id: exerciseId } = zx.parseParams(params, paramsSchema); const query = zx.parseQuery(request, searchParamsSchema); - const workoutInProgress = !!getWorkoutCookieValue(request); const [exerciseParameters, { exerciseDetails }, { userExerciseDetails }] = await Promise.all([ getCachedExerciseParameters(), @@ -125,7 +124,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { query, exerciseId, exerciseDetails, - workoutInProgress, exerciseParameters, userExerciseDetails, }; @@ -173,6 +171,7 @@ export default function Page() { loaderData.userExerciseDetails.details?.exerciseNumTimesInteracted || 0; const [currentWorkout, setCurrentWorkout] = useCurrentWorkout(); const navigate = useNavigate(); + const isFitnessActionActive = useIsFitnessActionActive(); const [_a, setAddEntityToCollectionData] = useAddEntityToCollection(); const [timeSpanForCharts, setTimeSpanForCharts] = useLocalStorage( "ExerciseChartTimeSpan", @@ -636,7 +635,7 @@ export default function Page() { ) : null} - {currentWorkout && loaderData.workoutInProgress ? ( + {currentWorkout && isFitnessActionActive ? ( { const cookieName = await getEnhancedCookieName("exercises.list", request); await redirectUsingEnhancedCookieSearchParams(request, cookieName); const query = zx.parseQuery(request, searchParamsSchema); - const workoutInProgress = !!getWorkoutCookieValue(request); query.sortBy = query.sortBy ?? defaultFiltersValue.sortBy; query[pageQueryParam] = query[pageQueryParam] ?? 1; const [exerciseParameters, { exercisesList }] = await Promise.all([ @@ -126,7 +125,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { totalPages, cookieName, exercisesList, - workoutInProgress, exerciseParameters, }; }; @@ -140,6 +138,7 @@ export default function Page() { const navigate = useNavigate(); const userPreferences = useUserPreferences(); const [currentWorkout, setCurrentWorkout] = useCurrentWorkout(); + const isFitnessActionActive = useIsFitnessActionActive(); const [_, { setP }] = useAppSearchParam(loaderData.cookieName); const [selectedExercises, setSelectedExercises] = useListState<{ name: string; @@ -159,7 +158,7 @@ export default function Page() { const allowAddingExerciseToWorkout = currentWorkout && - loaderData.workoutInProgress && + isFitnessActionActive && !isNumber(currentWorkout.replacingExerciseIdx); return ( diff --git a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx index 152f6d13b6..3d926e60d9 100644 --- a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx +++ b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx @@ -1679,7 +1679,6 @@ const DisplaySeasonOrEpisodeDetails = (props: { publishDate?: string | null; }) => { const [parent] = useAutoAnimate(); - const [displayOverview, setDisplayOverview] = useDisclosure(false); const swt = (t: string) => ( {t} @@ -1696,11 +1695,6 @@ const DisplaySeasonOrEpisodeDetails = (props: { : null, props.publishDate ? swt(dayjsLib(props.publishDate).format("ll")) : null, props.numEpisodes ? swt(`${props.numEpisodes} episodes`) : null, - props.overview ? ( - - {displayOverview ? "Hide" : "Show"} overview - - ) : null, ].filter((s) => s !== null); const display = filteredElements.length > 0 @@ -1727,23 +1721,23 @@ const DisplaySeasonOrEpisodeDetails = (props: { @@ -1758,13 +1752,13 @@ const DisplaySeasonOrEpisodeDetails = (props: { - {props.overview && displayOverview ? ( + {props.overview ? ( ) : null} diff --git a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx index 6f11da7b74..a6b909467c 100644 --- a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx @@ -43,6 +43,7 @@ import { import { camelCase, changeCase, + cn, isNumber, snakeCase, startCase, @@ -54,7 +55,6 @@ import { IconGripVertical, IconRotate360, } from "@tabler/icons-react"; -import clsx from "clsx"; import { Fragment, useState } from "react"; import { match } from "ts-pattern"; import { z } from "zod"; @@ -65,11 +65,11 @@ import { useComplexJsonUpdate, useConfirmSubmit, useDashboardLayoutData, + useIsFitnessActionActive, useUserPreferences, } from "~/lib/hooks"; import { createToastHeaders, - isWorkoutActive, redirectIfNotAuthenticatedOrUpdated, serverGqlService, } from "~/lib/utilities.server"; @@ -81,8 +81,7 @@ const searchSchema = z.object({ export const loader = async ({ request }: LoaderFunctionArgs) => { const query = zx.parseQuery(request, searchSchema); - const workoutInProgress = isWorkoutActive(request); - return { query, workoutInProgress }; + return { query }; }; export const meta = (_args: MetaArgs) => { @@ -131,6 +130,7 @@ export default function Page() { const loaderData = useLoaderData(); const userPreferences = useUserPreferences(); const submit = useConfirmSubmit(); + const isFitnessActionActive = useIsFitnessActionActive(); const [watchProviders, setWatchProviders] = useState( userPreferences.general.watchProviders.map((wp) => ({ ...wp, @@ -153,7 +153,7 @@ export default function Page() {
diff --git a/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx b/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx index 35d6e970c9..b75f267c60 100644 --- a/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx +++ b/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx @@ -288,15 +288,20 @@ export default function Page() { visiting {applicationBaseUrl}/u/{userDetails.name} - + + diff --git a/apps/frontend/app/routes/_dashboard.tsx b/apps/frontend/app/routes/_dashboard.tsx index 04fc27c855..24bc79f7ea 100644 --- a/apps/frontend/app/routes/_dashboard.tsx +++ b/apps/frontend/app/routes/_dashboard.tsx @@ -99,7 +99,6 @@ import { IconSun, } from "@tabler/icons-react"; import { produce } from "immer"; -import { jwtDecode } from "jwt-decode"; import { type FormEvent, useState } from "react"; import { Fragment } from "react/jsx-runtime"; import { $path } from "remix-routes"; @@ -120,6 +119,7 @@ import { useConfirmSubmit, useCoreDetails, useGetWatchProviders, + useIsFitnessActionActive, useMetadataDetails, useUserCollections, useUserDetails, @@ -134,11 +134,10 @@ import { useReviewEntity, } from "~/lib/state/media"; import { - getAuthorizationCookie, getCachedCoreDetails, getCachedUserCollectionsList, getCachedUserPreferences, - isWorkoutActive, + getDecodedJwt, redirectIfNotAuthenticatedOrUpdated, } from "~/lib/utilities.server"; import { colorSchemeCookie } from "~/lib/utilities.server"; @@ -240,9 +239,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { request.headers.get("cookie") || "", ); - const decodedCookie = jwtDecode<{ - access_link?: { id: string; is_demo?: boolean }; - }>(getAuthorizationCookie(request) ?? ""); + const decodedCookie = getDecodedJwt(request); const isAccessLinkSession = Boolean(decodedCookie?.access_link); const isDemo = Boolean(decodedCookie?.access_link?.is_demo); @@ -252,8 +249,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { !coreDetails.disableTelemetry && !isDemo; - const workoutInProgress = isWorkoutActive(request); - return { isDemo, mediaLinks, @@ -264,7 +259,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { userPreferences, shouldHaveUmami, userCollections, - workoutInProgress, currentColorScheme, isAccessLinkSession, }; @@ -352,6 +346,7 @@ export default function Layout() { const userDetails = useUserDetails(); const [parent] = useAutoAnimate(); const submit = useConfirmSubmit(); + const isFitnessActionActive = useIsFitnessActionActive(); const [openedLinkGroups, setOpenedLinkGroups] = useLocalStorage< | { media: boolean; @@ -405,7 +400,7 @@ export default function Layout() { return ( <> - {loaderData.workoutInProgress && + {isFitnessActionActive && !Object.values(FitnessAction) .map((action) => $path("/fitness/:action", { action })) .includes(location.pathname) ? ( diff --git a/apps/frontend/package.json b/apps/frontend/package.json index a3d0a45abf..4d67783249 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -56,6 +56,7 @@ "recharts": "2.13.1", "remix-routes": "1.7.7", "remix-utils": "7.7.0", + "tailwind-merge": "2.5.4", "tiny-invariant": "1.3.3", "ts-pattern": "5.5.0", "ufo": "1.5.4", diff --git a/apps/website/app/drizzle/migrations/0003_same_gorilla_man.sql b/apps/website/app/drizzle/migrations/0003_same_gorilla_man.sql new file mode 100644 index 0000000000..c6ed0eedee --- /dev/null +++ b/apps/website/app/drizzle/migrations/0003_same_gorilla_man.sql @@ -0,0 +1,3 @@ +ALTER TYPE "public"."plan_type" ADD VALUE 'free' BEFORE 'monthly';--> statement-breakpoint +ALTER TABLE "customer" DROP CONSTRAINT "customer_paddle_first_transaction_id_unique";--> statement-breakpoint +ALTER TABLE "customer" DROP COLUMN IF EXISTS "paddle_first_transaction_id"; \ No newline at end of file diff --git a/apps/website/app/drizzle/migrations/meta/0003_snapshot.json b/apps/website/app/drizzle/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000000..5165cc0830 --- /dev/null +++ b/apps/website/app/drizzle/migrations/meta/0003_snapshot.json @@ -0,0 +1,174 @@ +{ + "id": "606551ca-2277-4f7c-a916-7be8a145bc9f", + "prevId": "706194d8-7177-4603-9a70-49955b7b2c2f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contact_submission": { + "name": "contact_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_spam": { + "name": "is_spam", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_addressed": { + "name": "is_addressed", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "public.customer": { + "name": "customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_on": { + "name": "created_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "oidc_issuer_id": { + "name": "oidc_issuer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paddle_customer_id": { + "name": "paddle_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "product_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "plan_type": { + "name": "plan_type", + "type": "plan_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "renew_on": { + "name": "renew_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "unkey_key_id": { + "name": "unkey_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ryot_user_id": { + "name": "ryot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_cancelled": { + "name": "has_cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "customer_email_unique": { + "name": "customer_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "customer_oidc_issuer_id_unique": { + "name": "customer_oidc_issuer_id_unique", + "nullsNotDistinct": false, + "columns": ["oidc_issuer_id"] + }, + "customer_paddle_customer_id_unique": { + "name": "customer_paddle_customer_id_unique", + "nullsNotDistinct": false, + "columns": ["paddle_customer_id"] + } + }, + "checkConstraints": {} + } + }, + "enums": { + "public.plan_type": { + "name": "plan_type", + "schema": "public", + "values": ["free", "monthly", "yearly", "lifetime"] + }, + "public.product_type": { + "name": "product_type", + "schema": "public", + "values": ["cloud", "self_hosted"] + } + }, + "schemas": {}, + "sequences": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/website/app/drizzle/migrations/meta/_journal.json b/apps/website/app/drizzle/migrations/meta/_journal.json index 47f22e1cc9..d27dc08d55 100644 --- a/apps/website/app/drizzle/migrations/meta/_journal.json +++ b/apps/website/app/drizzle/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1723715178311, "tag": "0002_sad_pixie", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1730122312096, + "tag": "0003_same_gorilla_man", + "breakpoints": true } ] } diff --git a/apps/website/app/drizzle/schema.server.ts b/apps/website/app/drizzle/schema.server.ts index 900e28d3a0..cdd9e48071 100644 --- a/apps/website/app/drizzle/schema.server.ts +++ b/apps/website/app/drizzle/schema.server.ts @@ -13,10 +13,19 @@ export const productTypes = pgEnum("product_type", ["cloud", "self_hosted"]); export const ProductTypes = z.enum(productTypes.enumValues); -export const planTypes = pgEnum("plan_type", ["monthly", "yearly", "lifetime"]); +export type TProductTypes = z.infer; + +export const planTypes = pgEnum("plan_type", [ + "free", + "monthly", + "yearly", + "lifetime", +]); export const PlanTypes = z.enum(planTypes.enumValues); +export type TPlanTypes = z.infer; + export const customers = pgTable("customer", { id: uuid("id").notNull().primaryKey().defaultRandom(), email: text("email").notNull().unique(), @@ -25,7 +34,6 @@ export const customers = pgTable("customer", { .notNull(), oidcIssuerId: text("oidc_issuer_id").unique(), paddleCustomerId: text("paddle_customer_id").unique(), - paddleFirstTransactionId: text("paddle_first_transaction_id").unique(), productType: productTypes("product_type"), planType: planTypes("plan_type"), renewOn: date("renew_on"), diff --git a/apps/website/app/entry.server.tsx b/apps/website/app/entry.server.tsx index 1106cf9cd2..fe526ee035 100644 --- a/apps/website/app/entry.server.tsx +++ b/apps/website/app/entry.server.tsx @@ -2,27 +2,15 @@ import { PassThrough } from "node:stream"; import type { AppLoadContext, EntryContext } from "@remix-run/node"; import { createReadableStreamFromReadable } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; -import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import { isbot } from "isbot"; -import postgres from "postgres"; import { renderToPipeableStream } from "react-dom/server"; -import { serverVariables } from "./lib/config.server"; +import { db } from "./lib/config.server"; -const migrationClient = postgres(serverVariables.DATABASE_URL, { max: 1 }); - -migrate(drizzle(migrationClient), { - migrationsFolder: "app/drizzle/migrations", -}) - .then(() => { - migrationClient.end(); - console.log("Database migrations complete"); - }) - .catch((error) => { - console.error("Database migrations failed", error); - migrationClient.end(); - process.exit(1); - }); +migrate(db, { migrationsFolder: "app/drizzle/migrations" }).catch((error) => { + console.error("Database migrations failed", error); + process.exit(1); +}); const ABORT_DELAY = 5_000; diff --git a/apps/website/app/lib/components/Pricing.tsx b/apps/website/app/lib/components/Pricing.tsx index 6118103e8e..05f160132a 100644 --- a/apps/website/app/lib/components/Pricing.tsx +++ b/apps/website/app/lib/components/Pricing.tsx @@ -14,18 +14,18 @@ import { export default function Pricing(props: { prices: TPrices; - isLoggedIn: boolean; + isLoggedIn?: boolean; onClick?: (priceId: string) => void; }) { const [selectedProductTypeIndex, setSelectedProductTypeIndex] = useState(0); const selectedProductType = props.prices[selectedProductTypeIndex]; return ( -
+
-
+
Pricing

@@ -56,10 +56,7 @@ export default function Pricing(props: { .{" "} {selectedProductType.type === "self_hosted" ? ( - + See differences. ) : null} @@ -67,7 +64,7 @@ export default function Pricing(props: {
{selectedProductType.prices.map((p) => (

{changeCase(p.name)}

diff --git a/apps/website/app/lib/components/ui/button.tsx b/apps/website/app/lib/components/ui/button.tsx index ba912f2723..a63eee99de 100644 --- a/apps/website/app/lib/components/ui/button.tsx +++ b/apps/website/app/lib/components/ui/button.tsx @@ -1,7 +1,7 @@ import { Slot } from "@radix-ui/react-slot"; +import { cn } from "@ryot/ts-utils"; import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; -import { cn } from "~/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", diff --git a/apps/website/app/lib/components/ui/card.tsx b/apps/website/app/lib/components/ui/card.tsx index ea25b83094..ca2c3d7072 100644 --- a/apps/website/app/lib/components/ui/card.tsx +++ b/apps/website/app/lib/components/ui/card.tsx @@ -1,7 +1,6 @@ +import { cn } from "@ryot/ts-utils"; import * as React from "react"; -import { cn } from "app/lib/utils"; - const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes diff --git a/apps/website/app/lib/components/ui/carousel.tsx b/apps/website/app/lib/components/ui/carousel.tsx index 60c760410e..4cf557d300 100644 --- a/apps/website/app/lib/components/ui/carousel.tsx +++ b/apps/website/app/lib/components/ui/carousel.tsx @@ -1,12 +1,11 @@ +import { cn } from "@ryot/ts-utils"; +import { Button } from "app/lib/components/ui/button"; import useEmblaCarousel, { type UseEmblaCarouselType, } from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import * as React from "react"; -import { Button } from "app/lib/components/ui/button"; -import { cn } from "app/lib/utils"; - type CarouselApi = UseEmblaCarouselType[1]; type UseCarouselParameters = Parameters; type CarouselOptions = UseCarouselParameters[0]; diff --git a/apps/website/app/lib/components/ui/input-otp.tsx b/apps/website/app/lib/components/ui/input-otp.tsx index 2778f55fa5..eea7d70344 100644 --- a/apps/website/app/lib/components/ui/input-otp.tsx +++ b/apps/website/app/lib/components/ui/input-otp.tsx @@ -1,9 +1,8 @@ +import { cn } from "@ryot/ts-utils"; import { OTPInput, OTPInputContext } from "input-otp"; import { Dot } from "lucide-react"; import * as React from "react"; -import { cn } from "app/lib/utils"; - const InputOTP = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/apps/website/app/lib/components/ui/input.tsx b/apps/website/app/lib/components/ui/input.tsx index 8a5c0fcaf3..4cbd8bfb58 100644 --- a/apps/website/app/lib/components/ui/input.tsx +++ b/apps/website/app/lib/components/ui/input.tsx @@ -1,7 +1,6 @@ +import { cn } from "@ryot/ts-utils"; import * as React from "react"; -import { cn } from "app/lib/utils"; - export interface InputProps extends React.InputHTMLAttributes {} diff --git a/apps/website/app/lib/components/ui/label.tsx b/apps/website/app/lib/components/ui/label.tsx index b1826a3e78..9a9c6014a9 100644 --- a/apps/website/app/lib/components/ui/label.tsx +++ b/apps/website/app/lib/components/ui/label.tsx @@ -1,9 +1,8 @@ import * as LabelPrimitive from "@radix-ui/react-label"; +import { cn } from "@ryot/ts-utils"; import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; -import { cn } from "app/lib/utils"; - const labelVariants = cva( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", ); diff --git a/apps/website/app/lib/components/ui/textarea.tsx b/apps/website/app/lib/components/ui/textarea.tsx index 3d5d49598d..226b1b054e 100644 --- a/apps/website/app/lib/components/ui/textarea.tsx +++ b/apps/website/app/lib/components/ui/textarea.tsx @@ -1,7 +1,6 @@ +import { cn } from "@ryot/ts-utils"; import * as React from "react"; -import { cn } from "app/lib/utils"; - export interface TextareaProps extends React.TextareaHTMLAttributes {} diff --git a/apps/website/app/lib/components/ui/tooltip.tsx b/apps/website/app/lib/components/ui/tooltip.tsx index 9743001a0f..7c740468ce 100644 --- a/apps/website/app/lib/components/ui/tooltip.tsx +++ b/apps/website/app/lib/components/ui/tooltip.tsx @@ -1,8 +1,7 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { cn } from "@ryot/ts-utils"; import * as React from "react"; -import { cn } from "app/lib/utils"; - const TooltipProvider = TooltipPrimitive.Provider; const Tooltip = TooltipPrimitive.Root; diff --git a/apps/website/app/lib/config.server.ts b/apps/website/app/lib/config.server.ts index 63e670ef84..9ee23b7a18 100644 --- a/apps/website/app/lib/config.server.ts +++ b/apps/website/app/lib/config.server.ts @@ -1,11 +1,14 @@ import { Environment, Paddle } from "@paddle/paddle-node-sdk"; import { render } from "@react-email/render"; import { createCookie } from "@remix-run/node"; +import { formatDateToNaiveDate } from "@ryot/ts-utils"; +import { Unkey } from "@unkey/api"; +import type { Dayjs } from "dayjs"; +import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import { GraphQLClient } from "graphql-request"; import { createTransport } from "nodemailer"; import { Issuer } from "openid-client"; -import postgres from "postgres"; import { Honeypot } from "remix-utils/honeypot/server"; import { z } from "zod"; import { zx } from "zodix"; @@ -33,6 +36,7 @@ export const serverVariablesSchema = z.object({ SERVER_OIDC_ISSUER_URL: z.string(), SERVER_OIDC_CLIENT_SECRET: z.string(), SERVER_ADMIN_ACCESS_TOKEN: z.string(), + PADDLE_WEBHOOK_SECRET_KEY: z.string(), PADDLE_SANDBOX: zx.BoolAsString.optional(), }); @@ -42,14 +46,14 @@ export const OAUTH_CALLBACK_URL = `${serverVariables.FRONTEND_URL}/callback`; export const pricesSchema = z.array( z.object({ - type: z.string(), + type: z.nativeEnum(ProductTypes.Values), prices: z.array( z.object({ - name: z.string(), - linkToGithub: z.boolean().optional(), - priceId: z.string().optional(), - amount: z.number().optional(), trial: z.number().optional(), + amount: z.number().optional(), + priceId: z.string().optional(), + linkToGithub: z.boolean().optional(), + name: z.nativeEnum(PlanTypes.Values), }), ), }), @@ -62,20 +66,14 @@ export const prices = pricesSchema.parse( ); export const getProductAndPlanTypeByPriceId = (priceId: string) => { - for (const product of prices) { - for (const price of product.prices) { - if (price.priceId === priceId) { - return { - productType: ProductTypes.parse(product.type), - planType: PlanTypes.parse(price.name), - }; - } - } - } + for (const product of prices) + for (const price of product.prices) + if (price.priceId === priceId) + return { productType: product.type, planType: price.name }; throw new Error("Price ID not found"); }; -export const db = drizzle(postgres(serverVariables.DATABASE_URL), { +export const db = drizzle(serverVariables.DATABASE_URL, { schema, logger: serverVariables.NODE_ENV === "development", }); @@ -112,6 +110,7 @@ export const sendEmail = async ( }); const html = await render(element, { pretty: true }); const text = await render(element, { plainText: true }); + console.log(`Sending email to ${recipient} with subject ${subject}`); const resp = await client.sendMail({ text, html, @@ -122,15 +121,18 @@ export const sendEmail = async ( return resp.messageId; }; -export const authCookie = createCookie("WebsiteAuth", { +export const websiteAuthCookie = createCookie("WebsiteAuth", { maxAge: 60 * 60 * 24 * 365, path: "/", }); -export const getUserIdFromCookie = async (request: Request) => { - const cookie = await authCookie.parse(request.headers.get("cookie")); - if (!cookie) return null; - return z.string().parse(cookie); +export const getCustomerFromCookie = async (request: Request) => { + const cookie = await websiteAuthCookie.parse(request.headers.get("cookie")); + if (!cookie || Object.keys(cookie).length === 0) return null; + const customerId = z.string().parse(cookie); + return await db.query.customers.findFirst({ + where: eq(schema.customers.id, customerId), + }); }; export const serverGqlService = new GraphQLClient( @@ -139,3 +141,24 @@ export const serverGqlService = new GraphQLClient( ); export const honeypot = new Honeypot(); + +export const customDataSchema = z.object({ + customerId: z.string(), +}); + +export type CustomData = z.infer; + +export const createUnkeyKey = async ( + customer: typeof schema.customers.$inferSelect, + renewOn?: Dayjs, +) => { + const unkey = new Unkey({ rootKey: serverVariables.UNKEY_ROOT_KEY }); + const created = await unkey.keys.create({ + name: customer.email, + externalId: customer.id, + apiId: serverVariables.UNKEY_API_ID, + meta: renewOn ? { expiry: formatDateToNaiveDate(renewOn) } : undefined, + }); + if (created.error) throw new Error(created.error.message); + return created.result; +}; diff --git a/apps/website/app/lib/utils.ts b/apps/website/app/lib/utils.ts index ac680b303c..1e7581e942 100644 --- a/apps/website/app/lib/utils.ts +++ b/apps/website/app/lib/utils.ts @@ -1,6 +1,7 @@ -import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { $path } from "remix-routes"; +import { withFragment } from "ufo"; -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +export const startUrl = withFragment($path("/"), "start-here"); + +export const logoUrl = + "https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png"; diff --git a/apps/website/app/root.tsx b/apps/website/app/root.tsx index 97444c2bb0..7ce77b0b33 100644 --- a/apps/website/app/root.tsx +++ b/apps/website/app/root.tsx @@ -17,7 +17,8 @@ import type { import { $path } from "remix-routes"; import { withFragment } from "ufo"; import { Toaster } from "./lib/components/ui/sonner"; -import { getUserIdFromCookie, honeypot } from "./lib/config.server"; +import { getCustomerFromCookie, honeypot } from "./lib/config.server"; +import { logoUrl, startUrl } from "./lib/utils"; export const links: LinksFunction = () => { return [ @@ -41,9 +42,9 @@ export const links: LinksFunction = () => { }; export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await getUserIdFromCookie(request); + const customer = await getCustomerFromCookie(request); return { - isLoggedIn: !!userId, + isLoggedIn: !!customer, honeypotInputProps: honeypot.getInputProps(), }; }; @@ -74,23 +75,15 @@ export default function App() {
- Ryot + Ryot Ryot

-
@@ -287,7 +285,7 @@ export default function Page() {

- {loaderData.isLoggedIn ? ( + {rootLoaderData?.isLoggedIn ? (
-
-
- -
+ +
-
+
Contact Us

@@ -437,7 +395,7 @@ export default function Page() { ) : ( @@ -459,11 +417,14 @@ export default function Page() { )}

-
+
-
+
Open Source

@@ -555,22 +516,3 @@ const Image = ({ src, alt, className }: ImageProps) => ( )} /> ); - -const showCarouselContents = [ - { - text: "You can selectively enable or disable the facets you want to track. Spend less time learning the platform and more time tracking what matters to you. Get started in minutes.", - image: "/group.png", - smallImage: "/desktop.png", - alt: "Grouped images", - }, - { - text: "Share your profile data with friends and family without compromising your privacy. Ryot allows you to create access links with limited access so that others can view your favorite movies without logging in.", - image: "/sharing.png", - alt: "Sharing images", - }, - { - text: "Browse your favorite genres and get recommendations based on your preferences. Ryot uses advanced algorithms to suggest movies, shows, and books you might like.", - image: "/genres.png", - alt: "Genres images", - }, -]; diff --git a/apps/website/app/routes/callback.tsx b/apps/website/app/routes/callback.tsx index 6b00293353..cde50aada6 100644 --- a/apps/website/app/routes/callback.tsx +++ b/apps/website/app/routes/callback.tsx @@ -5,9 +5,9 @@ import { match } from "ts-pattern"; import { customers } from "~/drizzle/schema.server"; import { OAUTH_CALLBACK_URL, - authCookie, db, oauthClient, + websiteAuthCookie, } from "~/lib/config.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -37,6 +37,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { .otherwise((value) => value.id); if (!customerId) throw new Error("There was an error registering the user."); return redirect($path("/me"), { - headers: { "set-cookie": await authCookie.serialize(customerId) }, + headers: { "set-cookie": await websiteAuthCookie.serialize(customerId) }, }); }; diff --git a/apps/website/app/routes/comparison-to-community.tsx b/apps/website/app/routes/comparison-to-community.tsx deleted file mode 100644 index 86197617fc..0000000000 --- a/apps/website/app/routes/comparison-to-community.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import Autoplay from "embla-carousel-autoplay"; -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -} from "~/lib/components/ui/carousel"; -import { cn } from "~/lib/utils"; - -export default function Page() { - return ( -
-
-
-
- Comparison -
-
- - - {features.map((feature) => ( - -

- {feature.name} -

-

- {feature.points.join(" ")} -

-
- {feature.name} -
-
- ))} -
-
- - -
-
-
-
- ); -} - -const features = [ - { - name: "Sharing", - image: "/sharing-form.png", - points: [ - "Share your profile with your friends and family.", - "Set an expiry (or don't!) and revoke access at any time.", - "Anonymous users can only view your data, not change it.", - ], - }, - { - name: "Recommendations", - image: "/recommendations.png", - points: [ - 'A new section called "Recommendations" is added to the Dashboard where you can view personalized suggestions based on your recent media consumption. They are refreshed every hour.', - ], - }, - { - name: "Supercharged Collections", - image: "/supercharged-collections.png", - noBorder: true, - points: [ - 'Create information templates for your collections. These templates will be used when you add media to your collection. You can see an example of this on the community edition with the "Owned" and "Reminders" collection.', - "Add collaborators to your collections. This feature is useful when you want to share your collection with your friends or family. They can also add or remove media from it.", - ], - }, - { - name: "Other Enhancements", - image: "/other-enhancements.png", - points: [ - "Create templates to schedule future workouts.", - 'Option to automatically sync media items from integrations to the "Owned" collection.', - 'Easier navigation for the "History" tab in media details to your favorite episode.', - "Easily view history for an exercise and copy it to your current workout.", - "Add notes to individual sets in a workout.", - "Set time spent manually on seen entries, allowing for more accurate tracking of media consumption.", - ], - }, -]; diff --git a/apps/website/app/routes/features.tsx b/apps/website/app/routes/features.tsx new file mode 100644 index 0000000000..8675440711 --- /dev/null +++ b/apps/website/app/routes/features.tsx @@ -0,0 +1,287 @@ +import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/node"; +import { cn } from "@ryot/ts-utils"; +import Autoplay from "embla-carousel-autoplay"; +import { + LucideAmpersands, + LucideBadgeInfo, + LucideBellDot, + LucideBookHeart, + LucideCalendarRange, + LucideCandy, + LucideChartColumnBig, + LucideChartLine, + LucideCog, + LucideDatabaseZap, + LucideDumbbell, + LucideImageUp, + LucideImport, + LucideLayoutTemplate, + LucideLibraryBig, + LucideMegaphone, + LucideMessageSquareText, + LucideNotebookPen, + LucideNotebookTabs, + LucidePackageOpen, + LucideProjector, + LucideRefreshCcwDot, + LucideRuler, + LucideScale3D, + LucideShare, + LucideSquareStack, + LucideTimer, + LucideToggleLeft, + LucideVibrate, + LucideWatch, +} from "lucide-react"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "~/lib/components/ui/carousel"; +import { logoUrl } from "~/lib/utils"; + +export const loader = (_args: LoaderFunctionArgs) => { + return {}; +}; + +export const meta = (_args: MetaArgs) => { + return [{ title: "Features | Ryot" }]; +}; + +export default function Page() { + return ( +
+
+ Ryot +
+

+ Think of Ryot as your second brain with superpowers ✨ +

+

+ What all can Ryot do for you? +

+
+
+ {dataToDisplay.map((data, index) => ( +
+

+ {data.heading} +

+ {data.images.length > 0 ? ( + + + {data.images.map((image, index) => ( + + {`${data.heading}-${index + + ))} + +
+ + +
+
+ ) : null} +
+ {data.features.map((f) => ( +
+
+ + {f.isPro ? ( +
+ PRO +
+ ) : null} +
+

{f.text}

+
+ ))} +
+
+ ))} +
+ ); +} + +const dataToDisplay = [ + { + heading: "Media Tracking", + images: ["desktop.png", "genres.png", "group.png"], + features: [ + { + icon: LucideNotebookTabs, + text: "Track everything you want: movies, shows, books, podcasts, games, anime, manga, music, visual novels", + }, + { + icon: LucideLibraryBig, + text: "Add media to your watchlist, favorite or any other custom collection", + }, + { + icon: LucideBookHeart, + text: "Get recommendations based on your favorites and watch history", + isPro: true, + }, + { + icon: LucideNotebookPen, + text: "Track media you've watched and mark them as seen as many times as you want", + }, + { + icon: LucideImport, + text: "Import your data from 13 different sources (with more to come)", + }, + { + icon: LucideRefreshCcwDot, + text: "Integrations with 10 different services (with more on the way)", + }, + { + icon: LucideChartColumnBig, + text: "Consolidated activity and statistics graphs and views across all your media", + }, + { + icon: LucideWatch, + text: "Set time spent manually on seen entries for more accurate tracking of media consumption", + isPro: true, + }, + { + icon: LucideMegaphone, + text: "Get notifications when a new episode is released or your favorite actor is back on screen", + }, + { + icon: LucideVibrate, + text: "Support for 9 different notification platforms (more being released soon)", + }, + { + icon: LucideBellDot, + text: "Set reminders for when you want to watch something and get notified", + }, + { + icon: LucideMessageSquareText, + text: "Review media privately or publicly and see what others think", + }, + { + icon: LucideProjector, + text: "Get information on where you can watch a movie/show legally in your country", + }, + { + icon: LucidePackageOpen, + text: "Browse media by genre or groups (eg: Star Wars collection)", + }, + { + icon: LucideCalendarRange, + text: "Calendar view to get an overview on when a media is coming out", + }, + { + icon: LucideCandy, + text: "Suggestions that cater to your tastes based on your watch history", + }, + ], + }, + { + heading: "Fitness Tracking", + images: [ + "current-workout.png", + "measurements-graph.png", + "logged-workout.png", + "exercise-dataset.png", + ], + features: [ + { + icon: LucideDumbbell, + text: "Hit the gym and track workouts in realtime", + }, + { + icon: LucideDatabaseZap, + text: "Dataset of over 800 exercises with instructions (and the ability to add your own)", + }, + { + icon: LucideTimer, + text: "Add rest timers to each set you complete", + }, + { + icon: LucideImageUp, + text: "Create supersets and upload images for each exercise to track progression", + }, + { + icon: LucideSquareStack, + text: "Inline history and images of exercises while logging an active workout", + }, + { + icon: LucideLayoutTemplate, + text: "Create templates to pre plan workouts beforehand", + isPro: true, + }, + { + icon: LucideChartLine, + text: "Graphs of progress for exercises to visualize your progress over time", + }, + { + icon: LucideRuler, + text: "Keep track of your measurements like body weight, sugar level etc.", + }, + { + icon: LucideScale3D, + text: "Visualizations of how your measurements fluctuate over time. Use them to identify trends and patterns.", + }, + ], + }, + { + heading: "Other Goodies", + images: [ + "sharing.png", + "recommendations.png", + "sharing-form.png", + "supercharged-collections.png", + ], + features: [ + { + icon: LucideShare, + text: "Share access links to your data with your friends and family", + isPro: true, + }, + { + icon: LucideCog, + text: "Fine grained preferences to customize exactly what you want to track", + }, + { + icon: LucideAmpersands, + text: "Add collaborators to your collections to allow them to add to them", + isPro: true, + }, + { + icon: LucideToggleLeft, + text: "Dark and light mode, because Ryot is at your fingertips the whole time", + }, + { + icon: LucideBadgeInfo, + text: "Add custom information to your collections to make them more personalized", + isPro: true, + }, + ], + }, +]; diff --git a/apps/website/app/routes/me.tsx b/apps/website/app/routes/me.tsx index 0cd2df04ad..6acb7df8fa 100644 --- a/apps/website/app/routes/me.tsx +++ b/apps/website/app/routes/me.tsx @@ -3,242 +3,186 @@ import { type Paddle, initializePaddle, } from "@paddle/paddle-js"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { Form, redirect, useLoaderData, useSubmit } from "@remix-run/react"; -import { RegisterUserDocument } from "@ryot/generated/graphql/backend/graphql"; +import type { + ActionFunctionArgs, + LoaderFunctionArgs, + MetaArgs, +} from "@remix-run/node"; +import { Form, redirect, useLoaderData } from "@remix-run/react"; import PurchaseCompleteEmail from "@ryot/transactional/emails/PurchaseComplete"; -import { changeCase, getActionIntent } from "@ryot/ts-utils"; -import { Unkey } from "@unkey/api"; +import { + changeCase, + formatDateToNaiveDate, + getActionIntent, +} from "@ryot/ts-utils"; import dayjs from "dayjs"; import { eq } from "drizzle-orm"; -import { nanoid } from "nanoid"; import { useEffect, useState } from "react"; -import { $path } from "remix-routes"; -import { P, match } from "ts-pattern"; -import { withFragment, withQuery } from "ufo"; +import { toast } from "sonner"; +import { match } from "ts-pattern"; +import { withQuery } from "ufo"; import { customers } from "~/drizzle/schema.server"; import Pricing from "~/lib/components/Pricing"; import { Button } from "~/lib/components/ui/button"; import { Card } from "~/lib/components/ui/card"; import { Label } from "~/lib/components/ui/label"; import { - GRACE_PERIOD, - authCookie, + type CustomData, + createUnkeyKey, db, - getPaddleServerClient, - getProductAndPlanTypeByPriceId, - getUserIdFromCookie, + getCustomerFromCookie, prices, sendEmail, - serverGqlService, serverVariables, + websiteAuthCookie, } from "~/lib/config.server"; +import { startUrl } from "~/lib/utils"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await getUserIdFromCookie(request); - const isLoggedIn = !!userId; - if (!isLoggedIn) return redirect(withFragment($path("/"), "start-here")); - const planDetails = await match(userId) - .with(P.string, async (userId) => { - const customer = await db.query.customers.findFirst({ - where: eq(customers.id, userId), - columns: { - email: true, - renewOn: true, - planType: true, - productType: true, - unkeyKeyId: true, - ryotUserId: true, - }, - }); - if (!customer) return undefined; - return customer; - }) - .otherwise(() => undefined); + const customerDetails = await getCustomerFromCookie(request); + if (!customerDetails) return redirect(startUrl); return { prices, - planDetails, + customerDetails, isSandbox: !!serverVariables.PADDLE_SANDBOX, clientToken: serverVariables.PADDLE_CLIENT_TOKEN, }; }; +export const meta = (_args: MetaArgs) => { + return [{ title: "My account | Ryot" }]; +}; + export const action = async ({ request }: ActionFunctionArgs) => { const intent = getActionIntent(request); return await match(intent) - .with("logout", async () => { - const cookies = await authCookie.serialize("", { expires: new Date(0) }); - return Response.json({}, { headers: { "set-cookie": cookies } }); - }) - .with("processPurchase", async () => { - const userId = await getUserIdFromCookie(request); - if (!userId) - throw new Error("You must be logged in to buy a subscription"); - const { transactionId }: { transactionId: string } = await request.json(); - const paddleClient = getPaddleServerClient(); - const transaction = await paddleClient.transactions.get(transactionId); - const priceId = transaction.details?.lineItems[0].priceId; - if (!priceId) throw new Error("Price ID not found"); - const paddleCustomerId = transaction.customerId; - if (!paddleCustomerId) throw new Error("Paddle customer ID not found"); - const customer = await db.query.customers.findFirst({ - where: eq(customers.id, userId), - }); - if (!customer) throw new Error("Customer not found"); - const { email, oidcIssuerId } = customer; - const { planType, productType } = getProductAndPlanTypeByPriceId(priceId); - const renewOn = match(planType) - .with("lifetime", () => undefined) - .with("yearly", () => dayjs().add(1, "year")) - .with("monthly", () => dayjs().add(1, "month")) - .exhaustive(); - const { ryotUserId, unkeyKeyId, data } = await match(productType) - .with("cloud", async () => { - const password = nanoid(10); - const { registerUser } = await serverGqlService.request( - RegisterUserDocument, - { - input: { - adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN, - data: oidcIssuerId - ? { oidc: { email: email, issuerId: oidcIssuerId } } - : { password: { username: email, password: password } }, - }, - }, - ); - if (registerUser.__typename === "RegisterError") { - console.error(registerUser); - throw new Error("Failed to register user"); - } - return { - ryotUserId: registerUser.id, - unkeyKeyId: null, - data: { - __typename: "cloud" as const, - auth: oidcIssuerId ? email : { username: email, password }, - }, - }; - }) - .with("self_hosted", async () => { - const unkey = new Unkey({ rootKey: serverVariables.UNKEY_ROOT_KEY }); - const created = await unkey.keys.create({ - apiId: serverVariables.UNKEY_API_ID, - name: email, - meta: renewOn - ? { - expiry: renewOn - .add(GRACE_PERIOD, "days") - .format("YYYY-MM-DD"), - } - : undefined, - }); - if (created.error) throw new Error(created.error.message); - return { - ryotUserId: null, - unkeyKeyId: created.result.keyId, - data: { - __typename: "self_hosted" as const, - key: created.result.key, - }, - }; - }) - .exhaustive(); - const renewal = renewOn ? renewOn.format("YYYY-MM-DD") : undefined; + .with("regenerateUnkeyKey", async () => { + const customer = await getCustomerFromCookie(request); + if (!customer || !customer.planType) throw new Error("No customer found"); + const renewOn = customer.renewOn ? dayjs(customer.renewOn) : undefined; + const created = await createUnkeyKey(customer, renewOn); + await db + .update(customers) + .set({ unkeyKeyId: created.keyId }) + .where(eq(customers.id, customer.id)); + const renewal = renewOn ? formatDateToNaiveDate(renewOn) : undefined; await sendEmail( customer.email, PurchaseCompleteEmail.subject, - PurchaseCompleteEmail({ planType, renewOn: renewal, data }), - ); - await db - .update(customers) - .set({ - planType, - ryotUserId, - unkeyKeyId, - productType, + PurchaseCompleteEmail({ renewOn: renewal, - paddleCustomerId, - paddleFirstTransactionId: transactionId, - }) - .where(eq(customers.id, userId)); + planType: customer.planType, + details: { __typename: "self_hosted", key: created.key }, + }), + ); return Response.json({}); }) + .with("logout", async () => { + const cookies = await websiteAuthCookie.serialize("", { + expires: new Date(0), + }); + return Response.json({}, { headers: { "set-cookie": cookies } }); + }) .run(); }; export default function Index() { const loaderData = useLoaderData(); - const submit = useSubmit(); const [paddle, setPaddle] = useState(); + const paddleCustomerId = loaderData.customerDetails.paddleCustomerId; + useEffect(() => { - initializePaddle({ - environment: loaderData.isSandbox ? "sandbox" : undefined, - token: loaderData.clientToken, - eventCallback: (data) => { - if (data.name === CheckoutEventNames.CHECKOUT_COMPLETED) { - const transactionId = data.data?.transaction_id; - if (!transactionId) throw new Error("Transaction ID not found"); - submit( - { transactionId }, - { - method: "POST", - encType: "application/json", - action: withQuery(".", { intent: "processPurchase" }), + if (!paddle) + initializePaddle({ + token: loaderData.clientToken, + environment: loaderData.isSandbox ? "sandbox" : undefined, + }).then((paddleInstance) => { + if (paddleInstance) { + paddleInstance.Update({ + eventCallback: (data) => { + if (data.name === CheckoutEventNames.CHECKOUT_COMPLETED) { + paddleInstance.Checkout.close(); + toast.loading( + "Purchase successful. Your order will be shipped shortly.", + ); + setTimeout(() => window.location.reload(), 10000); + } }, - ); + }); + setPaddle(paddleInstance); } - }, - }).then((paddleInstance) => { - if (paddleInstance) setPaddle(paddleInstance); - }); + }); }, []); return ( <> - {loaderData.planDetails?.planType && - loaderData.planDetails.productType ? ( + {!loaderData.customerDetails.hasCancelled && + loaderData.customerDetails.planType && + loaderData.customerDetails.productType ? (
-
+

- {loaderData.planDetails.email} + {loaderData.customerDetails.email}

- {loaderData.planDetails.renewOn ? ( -
- -

- Renews on {loaderData.planDetails.renewOn} -

-
- ) : null}
- +

- {changeCase(loaderData.planDetails.planType)} + {changeCase(loaderData.customerDetails.productType)}

- +

- {changeCase(loaderData.planDetails.productType)} + {changeCase(loaderData.customerDetails.planType)}

- {loaderData.planDetails.ryotUserId ? ( + {loaderData.customerDetails.renewOn ? (
- +

- {loaderData.planDetails.ryotUserId} + Renews on {loaderData.customerDetails.renewOn}

) : null} - {loaderData.planDetails.unkeyKeyId ? ( + {loaderData.customerDetails.ryotUserId ? (
- +

- {loaderData.planDetails.unkeyKeyId} + {loaderData.customerDetails.ryotUserId} +

+
+ ) : null} + {loaderData.customerDetails.unkeyKeyId ? ( +
+
+ + + + +
+

+ {loaderData.customerDetails.unkeyKeyId} +

+

+ (This is the key ID; the pro key has been sent to your email)

) : null} @@ -251,6 +195,13 @@ export default function Index() { onClick={(priceId) => { paddle?.Checkout.open({ items: [{ priceId, quantity: 1 }], + customer: paddleCustomerId + ? { id: paddleCustomerId } + : { email: loaderData.customerDetails.email }, + customData: { + customerId: loaderData.customerDetails.id, + } as CustomData, + settings: paddleCustomerId ? { allowLogout: false } : undefined, }); }} /> diff --git a/apps/website/app/routes/paddle-webhook.tsx b/apps/website/app/routes/paddle-webhook.tsx new file mode 100644 index 0000000000..bb58ae4fd7 --- /dev/null +++ b/apps/website/app/routes/paddle-webhook.tsx @@ -0,0 +1,215 @@ +import { EventName } from "@paddle/paddle-node-sdk"; +import type { ActionFunctionArgs } from "@remix-run/node"; +import { + RegisterUserDocument, + UpdateUserDocument, +} from "@ryot/generated/graphql/backend/graphql"; +import PurchaseCompleteEmail from "@ryot/transactional/emails/PurchaseComplete"; +import { formatDateToNaiveDate } from "@ryot/ts-utils"; +import { Unkey } from "@unkey/api"; +import dayjs from "dayjs"; +import { eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { match } from "ts-pattern"; +import { type TPlanTypes, customers } from "~/drizzle/schema.server"; +import { + GRACE_PERIOD, + createUnkeyKey, + customDataSchema, + db, + getPaddleServerClient, + getProductAndPlanTypeByPriceId, + sendEmail, + serverGqlService, + serverVariables, +} from "~/lib/config.server"; + +const getRenewOnFromPlanType = (planType: TPlanTypes) => + match(planType) + .with("free", "lifetime", () => undefined) + .with("yearly", () => dayjs().add(1, "year")) + .with("monthly", () => dayjs().add(1, "month")) + .exhaustive(); + +export const action = async ({ request }: ActionFunctionArgs) => { + const paddleSignature = request.headers.get("paddle-signature"); + if (!paddleSignature) return Response.json({ error: "No paddle signature" }); + + const paddleClient = getPaddleServerClient(); + const requestBody = await request.text(); + const eventData = paddleClient.webhooks.unmarshal( + requestBody, + serverVariables.PADDLE_WEBHOOK_SECRET_KEY, + paddleSignature, + ); + if (!eventData) + return Response.json({ error: "No event data found in request body" }); + + const { eventType, data } = eventData; + + console.log(`Received event: ${eventType}`); + + if (eventType === EventName.TransactionCompleted) { + const paddleCustomerId = data.customerId; + if (!paddleCustomerId) + return Response.json({ + error: "No customer ID found in transaction completed event", + }); + console.log( + `Received transaction completed event for customer id: ${paddleCustomerId}`, + ); + let customer = await db.query.customers.findFirst({ + where: eq(customers.paddleCustomerId, paddleCustomerId), + }); + if (!customer) { + const parsed = customDataSchema.safeParse(data.customData); + if (parsed.success) + customer = await db.query.customers.findFirst({ + where: eq(customers.id, parsed.data.customerId), + }); + } + + if (!customer) + return Response.json({ + error: `No customer found for customer ID: ${paddleCustomerId}`, + }); + + const unkey = new Unkey({ rootKey: serverVariables.UNKEY_ROOT_KEY }); + if (!customer.planType) { + const priceId = data.details?.lineItems[0].priceId; + if (!priceId) return Response.json({ error: "Price ID not found" }); + + const { planType, productType } = getProductAndPlanTypeByPriceId(priceId); + console.log( + `Customer ${paddleCustomerId} purchased ${productType} with plan type ${planType}`, + ); + + const { email, oidcIssuerId } = customer; + const renewOn = getRenewOnFromPlanType(planType); + const { ryotUserId, unkeyKeyId, details } = await match(productType) + .with("cloud", async () => { + const password = nanoid(10); + const { registerUser } = await serverGqlService.request( + RegisterUserDocument, + { + input: { + adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN, + data: oidcIssuerId + ? { oidc: { email: email, issuerId: oidcIssuerId } } + : { password: { username: email, password: password } }, + }, + }, + ); + if (registerUser.__typename === "RegisterError") { + console.error(registerUser); + throw new Error("Failed to register user"); + } + return { + ryotUserId: registerUser.id, + unkeyKeyId: null, + details: { + __typename: "cloud" as const, + auth: oidcIssuerId ? email : { username: email, password }, + }, + }; + }) + .with("self_hosted", async () => { + const created = await createUnkeyKey( + customer, + renewOn ? renewOn.add(GRACE_PERIOD, "days") : undefined, + ); + return { + ryotUserId: null, + unkeyKeyId: created.keyId, + details: { + key: created.key, + __typename: "self_hosted" as const, + }, + }; + }) + .exhaustive(); + const renewal = renewOn ? formatDateToNaiveDate(renewOn) : undefined; + await sendEmail( + customer.email, + PurchaseCompleteEmail.subject, + PurchaseCompleteEmail({ planType, renewOn: renewal, details }), + ); + await db + .update(customers) + .set({ + planType, + ryotUserId, + unkeyKeyId, + productType, + renewOn: renewal, + paddleCustomerId, + }) + .where(eq(customers.id, customer.id)); + } else { + const renewal = getRenewOnFromPlanType(customer.planType); + const renewOn = renewal ? formatDateToNaiveDate(renewal) : undefined; + console.log(`Updating customer with renewOn: ${renewOn}`); + await db + .update(customers) + .set({ renewOn, hasCancelled: null }) + .where(eq(customers.id, customer.id)); + if (customer.ryotUserId) + await serverGqlService.request(UpdateUserDocument, { + input: { + isDisabled: false, + userId: customer.ryotUserId, + adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN, + }, + }); + if (customer.unkeyKeyId) + await unkey.keys.update({ + keyId: customer.unkeyKeyId, + meta: renewal + ? { + expiry: formatDateToNaiveDate( + renewal.add(GRACE_PERIOD, "days"), + ), + } + : undefined, + }); + } + } + + if ( + eventType === EventName.SubscriptionCanceled || + eventType === EventName.SubscriptionPaused || + eventType === EventName.SubscriptionPastDue + ) { + const customerId = data.customerId; + const customer = await db.query.customers.findFirst({ + where: eq(customers.paddleCustomerId, customerId), + }); + if (!customer) return Response.json({ message: "No customer found" }); + await db + .update(customers) + .set({ hasCancelled: true }) + .where(eq(customers.id, customer.id)); + if (customer.ryotUserId) + await serverGqlService.request(UpdateUserDocument, { + input: { + isDisabled: true, + userId: customer.ryotUserId, + adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN, + }, + }); + } + + if (eventType === EventName.SubscriptionResumed) { + const customerId = data.customerId; + const customer = await db.query.customers.findFirst({ + where: eq(customers.paddleCustomerId, customerId), + }); + if (!customer) return Response.json({ message: "No customer found" }); + await db + .update(customers) + .set({ hasCancelled: null }) + .where(eq(customers.id, customer.id)); + } + + return Response.json({ message: "Webhook ran successfully" }); +}; diff --git a/apps/website/app/routes/terms.tsx b/apps/website/app/routes/terms.tsx index 0a83bbc872..0bb5ccd3fe 100644 --- a/apps/website/app/routes/terms.tsx +++ b/apps/website/app/routes/terms.tsx @@ -1,5 +1,15 @@ +import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/node"; + const email = "ignisda2001@gmail.com"; +export const loader = (_args: LoaderFunctionArgs) => { + return {}; +}; + +export const meta = (_args: MetaArgs) => { + return [{ title: "Terms and conditions | Ryot" }]; +}; + export default function Index() { return (
diff --git a/apps/website/package.json b/apps/website/package.json index f7bc4e9add..974fb4b909 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -50,6 +50,7 @@ "sonner": "1.5.0", "tailwind-merge": "2.5.4", "tailwindcss-animate": "1.0.7", + "tiny-invariant": "1.3.3", "ts-pattern": "5.5.0", "ufo": "1.5.4", "zod": "3.23.8", diff --git a/apps/website/public/features/current-workout.png b/apps/website/public/features/current-workout.png new file mode 100644 index 0000000000..f7f8f83887 Binary files /dev/null and b/apps/website/public/features/current-workout.png differ diff --git a/apps/website/public/desktop.png b/apps/website/public/features/desktop.png similarity index 100% rename from apps/website/public/desktop.png rename to apps/website/public/features/desktop.png diff --git a/apps/website/public/features/exercise-dataset.png b/apps/website/public/features/exercise-dataset.png new file mode 100644 index 0000000000..8ef69e3156 Binary files /dev/null and b/apps/website/public/features/exercise-dataset.png differ diff --git a/apps/website/public/genres.png b/apps/website/public/features/genres.png similarity index 100% rename from apps/website/public/genres.png rename to apps/website/public/features/genres.png diff --git a/apps/website/public/group.png b/apps/website/public/features/group.png similarity index 100% rename from apps/website/public/group.png rename to apps/website/public/features/group.png diff --git a/apps/website/public/features/logged-workout.png b/apps/website/public/features/logged-workout.png new file mode 100644 index 0000000000..daa746f0db Binary files /dev/null and b/apps/website/public/features/logged-workout.png differ diff --git a/apps/website/public/features/measurements-graph.png b/apps/website/public/features/measurements-graph.png new file mode 100644 index 0000000000..a168ce0ae7 Binary files /dev/null and b/apps/website/public/features/measurements-graph.png differ diff --git a/apps/website/public/recommendations.png b/apps/website/public/features/recommendations.png similarity index 100% rename from apps/website/public/recommendations.png rename to apps/website/public/features/recommendations.png diff --git a/apps/website/public/sharing-form.png b/apps/website/public/features/sharing-form.png similarity index 100% rename from apps/website/public/sharing-form.png rename to apps/website/public/features/sharing-form.png diff --git a/apps/website/public/sharing.png b/apps/website/public/features/sharing.png similarity index 100% rename from apps/website/public/sharing.png rename to apps/website/public/features/sharing.png diff --git a/apps/website/public/supercharged-collections.png b/apps/website/public/features/supercharged-collections.png similarity index 100% rename from apps/website/public/supercharged-collections.png rename to apps/website/public/features/supercharged-collections.png diff --git a/apps/website/public/other-enhancements.png b/apps/website/public/other-enhancements.png deleted file mode 100644 index 551c4ba484..0000000000 Binary files a/apps/website/public/other-enhancements.png and /dev/null differ diff --git a/crates/services/collection/src/lib.rs b/crates/services/collection/src/lib.rs index 3691114ce4..147f381d9f 100644 --- a/crates/services/collection/src/lib.rs +++ b/crates/services/collection/src/lib.rs @@ -262,11 +262,11 @@ impl CollectionService { .await? .unwrap(); let reviews = item_reviews( - &self.0.db, &collection.user_id, &input.collection_id, EntityLot::Collection, true, + &self.0, ) .await?; Ok(CollectionContents { diff --git a/crates/services/exporter/src/lib.rs b/crates/services/exporter/src/lib.rs index 52d1606c22..e6b665d885 100644 --- a/crates/services/exporter/src/lib.rs +++ b/crates/services/exporter/src/lib.rs @@ -215,7 +215,7 @@ impl ExporterService { } }) .collect(); - let reviews = item_reviews(&self.0.db, user_id, &m.id, EntityLot::Metadata, false) + let reviews = item_reviews(user_id, &m.id, EntityLot::Metadata, false, &self.0) .await? .into_iter() .map(|r| self.get_review_export_item(r)) @@ -258,7 +258,7 @@ impl ExporterService { .await .unwrap() .unwrap(); - let reviews = item_reviews(&self.0.db, user_id, &m.id, EntityLot::MetadataGroup, false) + let reviews = item_reviews(user_id, &m.id, EntityLot::MetadataGroup, false, &self.0) .await? .into_iter() .map(|r| self.get_review_export_item(r)) @@ -300,7 +300,7 @@ impl ExporterService { .await .unwrap() .unwrap(); - let reviews = item_reviews(&self.0.db, user_id, &p.id, EntityLot::Person, false) + let reviews = item_reviews(user_id, &p.id, EntityLot::Person, false, &self.0) .await? .into_iter() .map(|r| self.get_review_export_item(r)) @@ -385,7 +385,7 @@ impl ExporterService { .await .unwrap() .unwrap(); - let reviews = item_reviews(&self.0.db, user_id, &e.id, EntityLot::Exercise, false) + let reviews = item_reviews(user_id, &e.id, EntityLot::Exercise, false, &self.0) .await? .into_iter() .map(|r| self.get_review_export_item(r)) diff --git a/crates/services/fitness/src/lib.rs b/crates/services/fitness/src/lib.rs index 5c49ae4259..acb330348a 100644 --- a/crates/services/fitness/src/lib.rs +++ b/crates/services/fitness/src/lib.rs @@ -256,14 +256,8 @@ impl ExerciseService { ) -> Result { let collections = entity_in_collections(&self.0.db, &user_id, &exercise_id, EntityLot::Exercise).await?; - let reviews = item_reviews( - &self.0.db, - &user_id, - &exercise_id, - EntityLot::Exercise, - true, - ) - .await?; + let reviews = + item_reviews(&user_id, &exercise_id, EntityLot::Exercise, true, &self.0).await?; let mut resp = UserExerciseDetails { details: None, history: None, diff --git a/crates/services/integration/src/lib.rs b/crates/services/integration/src/lib.rs index 965539399f..14d0943b4c 100644 --- a/crates/services/integration/src/lib.rs +++ b/crates/services/integration/src/lib.rs @@ -8,7 +8,7 @@ use database_models::{ prelude::{CollectionToEntity, Integration, Metadata, Seen, UserToEntity}, seen, user_to_entity, }; -use database_utils::user_preferences_by_id; +use database_utils::user_by_id; use dependent_models::ImportResult; use dependent_utils::{commit_metadata, process_import}; use enums::{EntityLot, IntegrationLot, IntegrationProvider, MediaLot}; @@ -97,7 +97,7 @@ impl IntegrationService { .one(&self.0.db) .await? .ok_or_else(|| Error::new("Integration does not exist".to_owned()))?; - let preferences = user_preferences_by_id(&integration.user_id, &self.0).await?; + let preferences = user_by_id(&integration.user_id, &self.0).await?.preferences; if integration.is_disabled.unwrap_or_default() || preferences.general.disable_integrations { return Err(Error::new("Integration is disabled".to_owned())); } @@ -259,7 +259,7 @@ impl IntegrationService { } pub async fn yank_integrations_data_for_user(&self, user_id: &String) -> GqlResult { - let preferences = user_preferences_by_id(user_id, &self.0).await?; + let preferences = user_by_id(user_id, &self.0).await?.preferences; if preferences.general.disable_integrations { return Ok(false); } diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs index 2dab99d625..7f8adee788 100644 --- a/crates/services/miscellaneous/src/lib.rs +++ b/crates/services/miscellaneous/src/lib.rs @@ -37,7 +37,6 @@ use database_utils::{ add_entity_to_collection, apply_collection_filter, calculate_user_activities_and_summary, entity_in_collections, entity_in_collections_with_collection_to_entity_ids, ilike_sql, item_reviews, remove_entity_from_collection, revoke_access_link, user_by_id, - user_preferences_by_id, }; use dependent_models::{ CoreDetails, GenreDetails, MetadataBaseData, MetadataGroupDetails, PersonDetails, @@ -549,14 +548,8 @@ ORDER BY RANDOM() LIMIT 10; let media_details = self.generic_metadata(&metadata_id).await?; let collections = entity_in_collections(&self.0.db, &user_id, &metadata_id, EntityLot::Metadata).await?; - let reviews = item_reviews( - &self.0.db, - &user_id, - &metadata_id, - EntityLot::Metadata, - true, - ) - .await?; + let reviews = + item_reviews(&user_id, &metadata_id, EntityLot::Metadata, true, &self.0).await?; let (_, history) = is_metadata_finished_by_user(&user_id, &metadata_id, &self.0.db).await?; let in_progress = history .iter() @@ -741,8 +734,7 @@ ORDER BY RANDOM() LIMIT 10; user_id: String, person_id: String, ) -> Result { - let reviews = - item_reviews(&self.0.db, &user_id, &person_id, EntityLot::Person, true).await?; + let reviews = item_reviews(&user_id, &person_id, EntityLot::Person, true, &self.0).await?; let collections = entity_in_collections(&self.0.db, &user_id, &person_id, EntityLot::Person).await?; Ok(UserPersonDetails { @@ -764,11 +756,11 @@ ORDER BY RANDOM() LIMIT 10; ) .await?; let reviews = item_reviews( - &self.0.db, &user_id, &metadata_group_id, EntityLot::MetadataGroup, true, + &self.0, ) .await?; Ok(UserMetadataGroupDetails { @@ -972,7 +964,7 @@ ORDER BY RANDOM() LIMIT 10; user_id: String, input: MetadataListInput, ) -> Result> { - let preferences = user_preferences_by_id(&user_id, &self.0).await?; + let preferences = user_by_id(&user_id, &self.0).await?.preferences; let avg_rating_col = "user_average_rating"; let cloned_user_id_1 = user_id.clone(); @@ -1570,7 +1562,7 @@ ORDER BY RANDOM() LIMIT 10; }); } let cloned_user_id = user_id.to_owned(); - let preferences = user_preferences_by_id(user_id, &self.0).await?; + let preferences = user_by_id(user_id, &self.0).await?.preferences; let provider = get_metadata_provider(input.lot, input.source, &self.0).await?; let results = provider .metadata_search(&query, input.search.page, preferences.general.display_nsfw) @@ -1645,7 +1637,7 @@ ORDER BY RANDOM() LIMIT 10; items: vec![], }); } - let preferences = user_preferences_by_id(user_id, &self.0).await?; + let preferences = user_by_id(user_id, &self.0).await?.preferences; let provider = self.get_non_metadata_provider(input.source).await?; let results = provider .people_search( @@ -1673,7 +1665,7 @@ ORDER BY RANDOM() LIMIT 10; items: vec![], }); } - let preferences = user_preferences_by_id(user_id, &self.0).await?; + let preferences = user_by_id(user_id, &self.0).await?.preferences; let provider = get_metadata_provider(input.lot, input.source, &self.0).await?; let results = provider .metadata_group_search(&query, input.search.page, preferences.general.display_nsfw) @@ -2486,7 +2478,7 @@ ORDER BY RANDOM() LIMIT 10; .unwrap(); comment.liked_by.remove(&user_id); } else { - let user = user_by_id(&self.0.db, &user_id).await?; + let user = user_by_id(&user_id, &self.0).await?; comments.push(ImportOrExportItemReviewComment { id: nanoid!(20), text: input.text.unwrap(), diff --git a/crates/services/user/src/lib.rs b/crates/services/user/src/lib.rs index 1d8b39ea54..7df4aa2b7d 100644 --- a/crates/services/user/src/lib.rs +++ b/crates/services/user/src/lib.rs @@ -14,7 +14,7 @@ use database_models::{ use database_utils::{ admin_account_guard, create_or_update_collection, deploy_job_to_calculate_user_activities_and_summary, ilike_sql, pro_instance_guard, - revoke_access_link, user_by_id, user_preferences_by_id, + revoke_access_link, user_by_id, }; use dependent_models::UserDetailsResult; use enum_meta::Meta; @@ -57,7 +57,7 @@ pub struct UserService(pub Arc); impl UserService { pub async fn user_recommendations(&self, user_id: &String) -> Result> { - let preferences = user_preferences_by_id(user_id, &self.0).await?; + let preferences = user_by_id(user_id, &self.0).await?.preferences; let limit = preferences .general .dashboard @@ -215,7 +215,7 @@ impl UserService { admin_user_id: String, to_delete_user_id: String, ) -> Result { - admin_account_guard(&self.0.db, &admin_user_id).await?; + admin_account_guard(&admin_user_id, &self.0).await?; let maybe_user = User::find_by_id(to_delete_user_id).one(&self.0.db).await?; let Some(u) = maybe_user else { return Ok(false); @@ -399,7 +399,7 @@ impl UserService { input: UpdateComplexJsonInput, ) -> Result { let err = || Error::new("Incorrect property value encountered"); - let user_model = user_by_id(&self.0.db, &user_id).await?; + let user_model = user_by_id(&user_id, &self.0).await?; let mut preferences = user_model.preferences.clone(); match input.property.is_empty() { true => preferences = UserPreferences::default(), @@ -977,8 +977,7 @@ impl UserService { error: UserDetailsErrorVariant::AuthTokenInvalid, })); }; - let mut user = user_by_id(&self.0.db, &user_id).await?; - user.preferences = user_preferences_by_id(&user_id, &self.0).await?; + let user = user_by_id(&user_id, &self.0).await?; Ok(UserDetailsResult::Ok(Box::new(user))) } diff --git a/crates/utils/database/src/lib.rs b/crates/utils/database/src/lib.rs index 0623a6345f..92a4703598 100644 --- a/crates/utils/database/src/lib.rs +++ b/crates/utils/database/src/lib.rs @@ -46,7 +46,7 @@ use sea_orm::{ }; use serde::{Deserialize, Serialize}; use supporting_service::SupportingService; -use user_models::{UserPreferences, UserReviewScale}; +use user_models::UserReviewScale; use uuid::Uuid; pub async fn revoke_access_link(db: &DatabaseConnection, access_link_id: String) -> Result { @@ -64,40 +64,32 @@ pub fn ilike_sql(value: &str) -> String { format!("%{value}%") } -pub async fn user_by_id(db: &DatabaseConnection, user_id: &String) -> Result { - User::find_by_id(user_id) - .one(db) - .await - .unwrap() - .ok_or_else(|| Error::new("No user found")) -} - -pub async fn user_preferences_by_id( - user_id: &String, - ss: &Arc, -) -> Result { - let mut preferences = user_by_id(&ss.db, user_id).await?.preferences; - preferences.features_enabled.media.anime = - ss.config.anime_and_manga.is_enabled() && preferences.features_enabled.media.anime; - preferences.features_enabled.media.audio_book = - ss.config.audio_books.is_enabled() && preferences.features_enabled.media.audio_book; - preferences.features_enabled.media.book = - ss.config.books.is_enabled() && preferences.features_enabled.media.book; - preferences.features_enabled.media.show = - ss.config.movies_and_shows.is_enabled() && preferences.features_enabled.media.show; - preferences.features_enabled.media.manga = - ss.config.anime_and_manga.is_enabled() && preferences.features_enabled.media.manga; - preferences.features_enabled.media.movie = - ss.config.movies_and_shows.is_enabled() && preferences.features_enabled.media.movie; - preferences.features_enabled.media.podcast = - ss.config.podcasts.is_enabled() && preferences.features_enabled.media.podcast; - preferences.features_enabled.media.video_game = - ss.config.video_games.is_enabled() && preferences.features_enabled.media.video_game; - Ok(preferences) +pub async fn user_by_id(user_id: &String, ss: &Arc) -> Result { + let mut user = User::find_by_id(user_id) + .one(&ss.db) + .await? + .ok_or_else(|| Error::new("No user found"))?; + let config = &ss.config; + let features_enabled = &mut user.preferences.features_enabled; + features_enabled.media.anime = + config.anime_and_manga.is_enabled() && features_enabled.media.anime; + features_enabled.media.audio_book = + config.audio_books.is_enabled() && features_enabled.media.audio_book; + features_enabled.media.book = config.books.is_enabled() && features_enabled.media.book; + features_enabled.media.show = + config.movies_and_shows.is_enabled() && features_enabled.media.show; + features_enabled.media.manga = + config.anime_and_manga.is_enabled() && features_enabled.media.manga; + features_enabled.media.movie = + config.movies_and_shows.is_enabled() && features_enabled.media.movie; + features_enabled.media.podcast = config.podcasts.is_enabled() && features_enabled.media.podcast; + features_enabled.media.video_game = + config.video_games.is_enabled() && features_enabled.media.video_game; + Ok(user) } -pub async fn admin_account_guard(db: &DatabaseConnection, user_id: &String) -> Result<()> { - let main_user = user_by_id(db, user_id).await?; +pub async fn admin_account_guard(user_id: &String, ss: &Arc) -> Result<()> { + let main_user = user_by_id(user_id, ss).await?; if main_user.lot != UserLot::Admin { return Err(Error::new(BackendError::AdminOnlyAction.to_string())); } @@ -401,12 +393,12 @@ pub async fn remove_entity_from_collection( } pub async fn item_reviews( - db: &DatabaseConnection, user_id: &String, entity_id: &String, entity_lot: EntityLot, // DEV: Setting this to true will return ALL user's reviews + public reviews by others get_public: bool, + ss: &Arc, ) -> Result> { let column = match entity_lot { EntityLot::Metadata => review::Column::MetadataId, @@ -426,7 +418,7 @@ pub async fn item_reviews( .find_also_related(User) .order_by_desc(review::Column::PostedOn) .filter(column.eq(entity_id)) - .all(db) + .all(&ss.db) .await .unwrap(); let mut reviews = vec![]; @@ -434,7 +426,7 @@ pub async fn item_reviews( let user = user.unwrap(); let rating = match true { true => { - let preferences = user_by_id(db, user_id).await?.preferences; + let preferences = user_by_id(user_id, ss).await?.preferences; review.rating.map(|s| { s.checked_div(match preferences.general.review_scale { UserReviewScale::OutOfFive => dec!(20), @@ -453,7 +445,7 @@ pub async fn item_reviews( .column(seen::Column::Id) .filter(seen::Column::ReviewId.eq(&review.id)) .into_tuple::() - .all(db) + .all(&ss.db) .await?; let to_push = ReviewItem { rating, diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs index 9cb5cf5982..c05a26ed4a 100644 --- a/crates/utils/dependent/src/lib.rs +++ b/crates/utils/dependent/src/lib.rs @@ -22,7 +22,6 @@ use database_models::{ use database_utils::{ add_entity_to_collection, admin_account_guard, create_or_update_collection, deploy_job_to_re_evaluate_user_workouts, remove_entity_from_collection, user_by_id, - user_preferences_by_id, }; use dependent_models::ImportResult; use enums::{ @@ -670,6 +669,7 @@ pub async fn update_metadata( ss, ) .await?; + ryot_log!(debug, "Updated metadata for {:?}", metadata_id); notifications } Err(e) => { @@ -682,7 +682,6 @@ pub async fn update_metadata( vec![] } }; - ryot_log!(debug, "Updated metadata for {:?}", metadata_id); Ok(notifications) } @@ -738,7 +737,7 @@ pub async fn queue_media_state_changed_notification_for_user( ss: &Arc, ) -> Result<()> { let (msg, change) = notification; - let notification_preferences = user_preferences_by_id(user_id, ss).await?.notifications; + let notification_preferences = user_by_id(user_id, ss).await?.preferences.notifications; if notification_preferences.enabled && notification_preferences.to_send.contains(change) { queue_notifications_to_user_platforms(user_id, msg, &ss.db) .await @@ -915,7 +914,7 @@ pub async fn deploy_background_job( | BackgroundJob::UpdateAllExercises | BackgroundJob::RecalculateCalendarEvents | BackgroundJob::PerformBackgroundTasks => { - admin_account_guard(&ss.db, user_id).await?; + admin_account_guard(user_id, ss).await?; } _ => {} } @@ -971,7 +970,7 @@ pub async fn post_review( input: CreateOrUpdateReviewInput, ss: &Arc, ) -> Result { - let preferences = user_preferences_by_id(user_id, ss).await?; + let preferences = user_by_id(user_id, ss).await?.preferences; if preferences.general.disable_reviews { return Err(Error::new("Reviews are disabled")); } @@ -1061,7 +1060,7 @@ pub async fn post_review( EntityLot::Exercise => id.clone(), EntityLot::Workout | EntityLot::WorkoutTemplate => unreachable!(), }; - let user = user_by_id(&ss.db, &insert.user_id.unwrap()).await?; + let user = user_by_id(&insert.user_id.unwrap(), ss).await?; // DEV: Do not send notification if updating a review if input.review_id.is_none() { ss.perform_core_application_job(CoreApplicationJob::ReviewPosted(ReviewPostedEvent { @@ -1961,7 +1960,7 @@ pub async fn process_import( ss: &Arc, ) -> Result { let mut import = import; - let preferences = user_by_id(&ss.db, user_id).await?.preferences; + let preferences = user_by_id(user_id, ss).await?.preferences; for m in import.metadata.iter_mut() { m.seen_history.sort_by(|a, b| { a.ended_on diff --git a/docs/content/configuration.md b/docs/content/configuration.md index b2758f3e69..f70b9367ab 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -1,26 +1,21 @@ # Configuration -You can specify configuration options via environment variables or via files (loaded from -`config/ryot.json`, `config/ryot.toml`, `config/ryot.yaml`). They should be present in `/home/ryot/config/ryot.`. +You can specify configuration options via environment variables. Each option is documented +[below](#all-parameters) with what it does and a default (if any). Ryot serves the final configuration loaded at the `/backend/config` endpoint as JSON -([example](https://pro.ryot.io/backend/config)). - -!!! info - - The defaults can be inspected in the - [config]({{ extra.file_path }}/libs/config/src/lib.rs) builder. +([example](https://pro.ryot.io/backend/config)). Sensitive variables are redacted. ## Important parameters -| Key / Environment variable | Description | -| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| - / `PORT` | The port to listen on. Defaults to `8000`. | -| - / `TZ` | Timezone to be used for cron jobs. Accepts values according to the IANA database. Defaults to `GMT`. | -| `disable_telemetry` / `DISABLE_TELEMETRY` | Disables telemetry collection using [Umami](https://umami.is). Defaults to `false`. | -| `database.url` / `DATABASE_URL` | The Postgres database connection string. | -| `video_games.twitch.client_id` / `VIDEO_GAMES_TWITCH_CLIENT_ID` | The client ID issued by Twitch. **Required** to enable video games tracking. [More information](guides/video-games.md) | -| `video_games.twitch.client_secret` / `VIDEO_GAMES_TWITCH_CLIENT_SECRET` | The client secret issued by Twitch. **Required** to enable video games tracking. | +| Environment variable | Description | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `PORT` | The port to listen on. Defaults to `8000`. | +| `TZ` | Timezone to be used for cron jobs. Accepts values according to the IANA database. Defaults to `GMT`. | +| `DISABLE_TELEMETRY` | Disables telemetry collection using [Umami](https://umami.is). Defaults to `false`. | +| `DATABASE_URL` | The Postgres database connection string. | +| `VIDEO_GAMES_TWITCH_CLIENT_ID` | The client ID issued by Twitch. **Required** to enable video games tracking. [More information](guides/video-games.md) | +| `VIDEO_GAMES_TWITCH_CLIENT_SECRET` | The client secret issued by Twitch. **Required** to enable video games tracking. | ## Health endpoint diff --git a/docs/content/guides/openid.md b/docs/content/guides/authentication.md similarity index 86% rename from docs/content/guides/openid.md rename to docs/content/guides/authentication.md index f7e45875fc..712de2074c 100644 --- a/docs/content/guides/openid.md +++ b/docs/content/guides/authentication.md @@ -1,4 +1,9 @@ -# OpenID Authentication +# Authentication + +Ryot supports multiple authentication methods. By default, it uses local authentication +which means that you can log in using a username and password. + +## OpenID Ryot can be configured to use OpenID Connect (OIDC) for authentication. The following environment variables need to be set: diff --git a/docs/content/guides/books.md b/docs/content/guides/books.md index f0069ebdd7..627242dd2a 100644 --- a/docs/content/guides/books.md +++ b/docs/content/guides/books.md @@ -28,5 +28,5 @@ Google Books. 6. Click on "Create" and copy the API key. -7. Set the `books.google_books.api_key` configuration variable in the environment as - described in the [configuration](../configuration.md#important-parameters) docs. +7. Set the `BOOKS_GOOGLE_BOOKS_API_KEY` environment variable as described in the + [configuration](../configuration.md#important-parameters) docs. diff --git a/docs/content/guides/exporting.md b/docs/content/guides/exporting.md index 5d85479987..5c6be1005d 100644 --- a/docs/content/guides/exporting.md +++ b/docs/content/guides/exporting.md @@ -44,7 +44,13 @@ the entire database and emailing the file. docker exec -u postgres -i ryot-db pg_dump -Fc --no-acl --no-owner > /tmp/ryot.file.sql ``` -## Type definition +To restore the above dump, run the following command: + +```bash +docker exec -u postgres -i ryot-db pg_restore -U postgres -d postgres < /tmp/ryot.file.sql +``` + +## Type definitions ```ts {% include 'export-schema.ts' %} diff --git a/docs/content/guides/video-games.md b/docs/content/guides/video-games.md index b57798c42a..2095a3e33a 100644 --- a/docs/content/guides/video-games.md +++ b/docs/content/guides/video-games.md @@ -26,5 +26,5 @@ You can follow the below steps to obtain your own API keys and enable video game 6. Generate a client secret. Copy the **Client ID** and **Client Secret**. -7. Set the `video_games.*` configuration variables in the environment as - described in the [configuration](../configuration.md#important-parameters) docs. +7. Set the `VIDEO_GAMES_*` environment variables as described in the + [configuration](../configuration.md#important-parameters) docs. diff --git a/docs/content/importing.md b/docs/content/importing.md index e6d3f95a4d..ab92f78de6 100644 --- a/docs/content/importing.md +++ b/docs/content/importing.md @@ -209,7 +209,7 @@ whose data you want to import. The "Generic Json" can be used to import all possible data from a generic JSON file. The format of the JSON file should be `CompleteExport` as described in the -[exporting](guides/exporting.md#type-definition) documentation. +[exporting](guides/exporting.md#type-definitions) documentation. You can use this to export all your data from one Ryot instance and import it into another, or from a source that is not supported by Ryot. diff --git a/docs/content/index.md b/docs/content/index.md index bddc15b6d2..aa7b0bf4ba 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -1,5 +1,7 @@ # Installation +Use the following docker-compose file: + ```yaml services: ryot-db: @@ -19,6 +21,7 @@ services: environment: - DATABASE_URL=postgres://postgres:postgres@ryot-db:5432/postgres - TZ=Europe/Amsterdam + - SERVER_ADMIN_ACCESS_TOKEN=28ebb3ae554fa9867ba0 # CHANGE THIS ports: - "8000:8000" pull_policy: always @@ -29,13 +32,12 @@ volumes: ``` If you would like to run the pro version, please check [below](#upgrading-to-pro). To see -the features of the pro version, check the [features page]({{extra.main_website_url -}}). +the features of the pro version, check the [features page]({{extra.main_website_url}}). ## Upgrading to Pro To upgrade to the pro version, you need to provide a `SERVER_PRO_KEY` environment variable. -You can get a key by purchasing it from the [website]({{ extra.main_website_url }}). +You can get a key by purchasing it from the [website]({{extra.main_website_url}}). Once you have the key, you can set it in the `docker-compose.yml` file: diff --git a/docs/content/integrations.md b/docs/content/integrations.md index c70a4a4b50..e5c3ceae0b 100644 --- a/docs/content/integrations.md +++ b/docs/content/integrations.md @@ -3,55 +3,11 @@ Integrations can be used to continuously update your media progress or inform external services about changes. They can be of following types: +- _Sink_: An external client publishes progress updates to the Ryot server. - _Yank_: Progress data is downloaded from an externally running server at a periodic interval. -- _Sink_: An external client publishes progress updates to the Ryot server. - _Push_: Ryot sends data to an external service when an event occurs. -## Yank integrations - -You can configure the interval at which the data is fetched from the external using the -`integration.sync_every_minutes` configuration key. Defaults to `5` (minutes). - -### Audiobookshelf - -!!! warning - - This will only import media that are in progress. Perform an - [import](./importing.md#audiobookshelf) if you want to import media that are finished. - -The [Audiobookshelf](https://www.audiobookshelf.org) integration can sync all media if they -have a valid provider ID (Audible, ITunes or ISBN). - -1. Obtain an API token as described in the Audiobookshelf - [authentication](https://api.audiobookshelf.org/#authentication) docs. -2. Go to your Ryot user settings and add the correct details as described in the - [yank](#yank-integrations) section. - -### Komga - -The [Komga](https://komga.org/) integration can sync all media if they -have a valid metadata provider. - -#### Steps - -If you use [Komf](https://github.com/Snd-R/komf) or some similar metadata provider these -urls will be populated automatically. If you don't use komf you will either need to -manually add the manga to your collection or you can perform the following steps. - -1. Navigate to the manga -2. Open the edit tab -3. Navigate to the Links tab -4. Create a link named `AniList` or `MyAnimeList` providing the respective url (not case-sensitive) - -Then perform these steps on Ryot - -1. Create the integration and select Komga as the source -2. Provide your BaseURL. Should look something like this `http://komga.acme.com` or `http://127.0.0.1:25600` -3. Provide your Username and Password. -4. Provide your preferred metadata provider. Ryot will attempt the others if the preferred - is unavailable and will fallback to title search otherwise. - ## Sink integrations These work via webhooks wherein an external service can inform Ryot about a change. All @@ -144,33 +100,76 @@ TMDb ID attached to their metadata. The "Generic Json" can be used to import all possible data using a generic JSON data format. The format of the JSON file should be `CompleteExport` as described in the -[exporting](guides/exporting.md#type-definition) documentation. +[exporting](guides/exporting.md#type-definitions) documentation. You can use this to build integrations with other services that Ryot does not support natively. +## Yank integrations + +You can configure the interval at which the data is fetched from the external source using +the `INTEGRATION_SYNC_EVERY_MINUTES` environment variable. Defaults to `5`. + +### Audiobookshelf + +!!! warning + + This will only import media that are in progress. Perform an + [import](./importing.md#audiobookshelf) if you want to import media that are finished. + +The [Audiobookshelf](https://www.audiobookshelf.org) integration can sync all media if they +have a valid provider ID (Audible, ITunes or ISBN). + +1. Obtain an API token as described in the Audiobookshelf + [authentication](https://api.audiobookshelf.org/#authentication) docs. +2. Go to your Ryot user settings and add the correct details as described in the + [yank](#yank-integrations) section. + +### Komga + +The [Komga](https://komga.org/) integration can sync all media if they +have a valid metadata provider. + +#### Steps + +If you use [Komf](https://github.com/Snd-R/komf) or some similar metadata provider these +urls will be populated automatically. If you don't, you will either need to manually add +the manga to your collection or you can perform the following steps. + +1. Navigate to the manga and open the Edit tab +3. Navigate to the Links tab +4. Create a link named `AniList` or `MyAnimeList` providing the respective url (not case-sensitive) + +Then perform these steps on Ryot + +1. Create an integration and select Komga as the source +2. Provide your Base URL. It should look something like this `http://komga.acme.com` or + `http://127.0.0.1:25600` +3. Provide your Username and Password. +4. Provide your preferred metadata provider. Ryot will attempt the others if the preferred + is unavailable and will fallback to title search otherwise. + ## Push integrations You can enable the following push integrations: ### Radarr -Automatically add movies in the selected collections to Radarr. +Events: `Item added to collection` 1. Obtain your Radarr API key by going to the Radarr general settings page. 2. Fill the inputs in the integration settings page with the correct details. ### Sonarr -Automatically add shows in the selected collections to Sonarr. +Events: `Item added to collection` 1. Obtain your Sonarr API key by going to the Sonarr general settings page. 2. Fill the inputs in the integration settings page with the correct details. ### Jellyfin -Automatically mark movies and shows as watched in Jellyfin when you mark them as watched -in Ryot. +Events: `Item marked as completed` 1. While creating the integration, you will be asked to provide your Jellyfin username and password. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 66cb5a030b..2ac4485008 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -4,7 +4,7 @@ site_name: Ryot Documentation site_url: https://docs.ryot.io site_description: Ryot Documentation repo_url: https://github.com/ignisda/ryot -dev_addr: 0.0.0.0:8000 +dev_addr: 0.0.0.0:8200 docs_dir: content theme: diff --git a/libs/transactional/emails/PurchaseComplete.tsx b/libs/transactional/emails/PurchaseComplete.tsx index 1acbb530e4..c3ed5da448 100644 --- a/libs/transactional/emails/PurchaseComplete.tsx +++ b/libs/transactional/emails/PurchaseComplete.tsx @@ -7,7 +7,7 @@ const proLink = "https://pro.ryot.io"; type PurchaseCompleteEmailProps = { planType: string; renewOn?: string; - data: + details: | { __typename: "self_hosted"; key: string } | { __typename: "cloud"; @@ -18,19 +18,19 @@ type PurchaseCompleteEmailProps = { const subject = "Thank you for buying Ryot!"; const PurchaseCompleteEmail = (props: PurchaseCompleteEmailProps) => - props.data ? ( + props.details ? ( You have successfully purchased a {props.planType} plan for Ryot Pro ( - {props.data.__typename}).{" "} + {props.details.__typename}).{" "} {props.renewOn ? `Your subscription will renew on ${props.renewOn}.` : null} - {props.data.__typename === "self_hosted" ? ( + {props.details.__typename === "self_hosted" ? ( <> - Your Pro Key is {props.data.key}. Please follow{" "} + Your Pro Key is {props.details.key}. Please follow{" "} these{" "} instructions to install/upgrade Ryot with your key. @@ -38,12 +38,12 @@ const PurchaseCompleteEmail = (props: PurchaseCompleteEmailProps) => <> Your account has been created on{" "} {proLink} with{" "} - {isString(props.data.auth) ? ( - `Google using the email ${props.data.auth}. Please login to get started` + {isString(props.details.auth) ? ( + `Google using the email ${props.details.auth}. Please login to get started` ) : ( <> - the username {props.data.auth.username} and - password {props.data.auth.password}. Please + the username {props.details.auth.username} and + password {props.details.auth.password}. Please login and change your password from the profile settings )} diff --git a/libs/ts-utils/package.json b/libs/ts-utils/package.json index 3fd78dd770..51ed72090f 100644 --- a/libs/ts-utils/package.json +++ b/libs/ts-utils/package.json @@ -4,9 +4,11 @@ "dependencies": { "@conform-to/zod": "1.2.2", "@ryot/generated": "workspace:*", + "clsx": "2.1.1", "dayjs": "1.11.13", "humanize-duration-ts": "2.1.1", "lodash": "4.17.21", + "tailwind-merge": "2.5.4", "tiny-invariant": "1.3.3" }, "devDependencies": { diff --git a/libs/ts-utils/src/index.ts b/libs/ts-utils/src/index.ts index a40a7c49cd..d0d0dc8a46 100644 --- a/libs/ts-utils/src/index.ts +++ b/libs/ts-utils/src/index.ts @@ -1,5 +1,6 @@ import { parseWithZod } from "@conform-to/zod"; -import dayjs from "dayjs"; +import { type ClassValue, clsx } from "clsx"; +import dayjs, { type Dayjs } from "dayjs"; import { HumanizeDuration, HumanizeDurationLanguage, @@ -25,6 +26,7 @@ import sortBy from "lodash/sortBy"; import startCase from "lodash/startCase"; import sum from "lodash/sum"; import truncate from "lodash/truncate"; +import { twMerge } from "tailwind-merge"; import invariant from "tiny-invariant"; import type { ZodTypeAny, output } from "zod"; @@ -43,9 +45,8 @@ export const humanizeDuration = ( /** * Format a `Date` into a Rust `NaiveDate` */ -export const formatDateToNaiveDate = (t: Date) => { - return dayjs(t).format("YYYY-MM-DD"); -}; +export const formatDateToNaiveDate = (t: Date | Dayjs) => + dayjs(t).format("YYYY-MM-DD"); /** * Generate initials for a given string. @@ -53,10 +54,10 @@ export const formatDateToNaiveDate = (t: Date) => { export const getInitials = (name: string) => { const rgx = new RegExp(/(\p{L}{1})\p{L}+/gu); const initials = [...name.matchAll(rgx)]; - const actuals = ( + const actualValues = ( (initials.shift()?.[1] || "") + (initials.pop()?.[1] || "") ).toUpperCase(); - return actuals; + return actualValues; }; /** @@ -86,6 +87,8 @@ export const getActionIntent = (request: Request) => { return intent; }; +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); + export { camelCase, cloneDeep, diff --git a/yarn.lock b/yarn.lock index c68992d7e3..d0176b9861 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6294,6 +6294,7 @@ __metadata: remix-development-tools: "npm:4.7.3" remix-routes: "npm:1.7.7" remix-utils: "npm:7.7.0" + tailwind-merge: "npm:2.5.4" tiny-invariant: "npm:1.3.3" ts-essentials: "npm:10.0.2" ts-pattern: "npm:5.5.0" @@ -6361,9 +6362,11 @@ __metadata: "@conform-to/zod": "npm:1.2.2" "@ryot/generated": "workspace:*" "@types/lodash": "npm:4.17.13" + clsx: "npm:2.1.1" dayjs: "npm:1.11.13" humanize-duration-ts: "npm:2.1.1" lodash: "npm:4.17.21" + tailwind-merge: "npm:2.5.4" tiny-invariant: "npm:1.3.3" languageName: unknown linkType: soft @@ -6426,6 +6429,7 @@ __metadata: tailwind-merge: "npm:2.5.4" tailwindcss: "npm:3.4.14" tailwindcss-animate: "npm:1.0.7" + tiny-invariant: "npm:1.3.3" ts-pattern: "npm:5.5.0" typescript: "npm:5.6.3" typescript-remix-routes-plugin: "npm:1.0.1"