refactor: Separate main application and external pages and SEO support (#987)

* init

* chore: move tw config

Signed-off-by: Innei <tukon479@gmail.com>

* chore: update deps

Signed-off-by: Innei <tukon479@gmail.com>

* chore: add peer deps

Signed-off-by: Innei <tukon479@gmail.com>

* init

* update

Signed-off-by: Innei <tukon479@gmail.com>

* test

Signed-off-by: Innei <tukon479@gmail.com>

* deps

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update hack

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* daisyui

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

* update

* update

Signed-off-by: Innei <tukon479@gmail.com>

* chore: auto-fix linting and formatting issues

* feat: masonry

Signed-off-by: Innei <tukon479@gmail.com>

* padding

Signed-off-by: Innei <tukon479@gmail.com>

* fix og

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* fix: masonry re-render

Signed-off-by: Innei <tukon479@gmail.com>

* update grid

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* add op

Signed-off-by: Innei <tukon479@gmail.com>

* fix: error boundary lazy suspense

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* follow

Signed-off-by: Innei <tukon479@gmail.com>

* feat: profile

Signed-off-by: Innei <tukon479@gmail.com>

* profile

Signed-off-by: Innei <tukon479@gmail.com>

* refactor: og

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* update

Signed-off-by: Innei <tukon479@gmail.com>

* chore: auto-fix linting and formatting issues

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: Innei <Innei@users.noreply.github.com>
This commit is contained in:
Innei 2024-10-21 23:02:25 +08:00 committed by GitHub
parent 2b98e6ef5e
commit 70acf916e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
247 changed files with 11556 additions and 370 deletions

5
.gitignore vendored
View File

@ -14,3 +14,8 @@ stats.html
electron.vite.config.*.mjs
vite.config.*.mjs
# apps/server/**/fonts-data.ts
# apps/server/**/index.template.ts
.generated

View File

@ -2,3 +2,5 @@ packages/shared/src/hono.ts
pnpm-lock.yaml
CHANGELOG.md
apps/external/postcss.config.cjs

View File

@ -60,7 +60,7 @@ The project is currently under active development. The following screenshots are
![Screenshot 2024-09-10 at 6 27 08PM](https://github.com/user-attachments/assets/82d71c46-c039-41b9-b3cd-5ad078ff14a5)
![Screenshot 2024-09-10 at 8 10 56PM](https://github.com/user-attachments/assets/2424dfd4-59a6-4dad-8eb7-8db61a711b45)
- Social features. Follow is also a social platform that allows you to follow other users, share your subscriptions, and discover new content. It also offers a subscription list synchronization feature, enabling your friends to sync with your subscriptions. Click [here](https://app.follow.is/profile/54728159538884608) to view my automatically generated personal page.
- Social features. Follow is also a social platform that allows you to follow other users, share your subscriptions, and discover new content. It also offers a subscription list synchronization feature, enabling your friends to sync with your subscriptions. Click [here](https://app.follow.is/share/users/54728159538884608) to view my automatically generated personal page.
![Screenshot 2024-09-10 at 6 26 37PM](https://github.com/user-attachments/assets/b636fd6d-0fcb-436b-8856-f7a965dba12d)
- Extensive customization options

View File

@ -52,6 +52,6 @@
"@types/node": "^22.7.5",
"electron": "33.0.0",
"electron-devtools-installer": "3.2.0",
"hono": "4.6.3"
"hono": "4.6.5"
}
}

View File

@ -10,10 +10,4 @@ declare global {
}
}
declare module "react" {
export interface AriaAttributes {
"data-testid"?: string
}
}
export {}

View File

@ -113,6 +113,7 @@
},
"devDependencies": {
"@babel/generator": "7.25.7",
"@follow/types": "workspace:*",
"@hono/node-server": "1.13.2",
"@types/katex": "0.16.7",
"@types/lodash-es": "4.17.12",

View File

@ -9,10 +9,10 @@ import { FeedForm } from "~/modules/discover/feed-form"
import { entries } from "~/queries/entries"
import type { AppErrorFallbackProps } from "../common/AppErrorBoundary"
import { FeedIcon } from "../feed-icon"
import { Button } from "../ui/button"
import { useModalStack } from "../ui/modal"
import { CustomSafeError, useResetErrorWhenRouteChange } from "./helper"
import { FeedPreview } from "./previews/FeedPreview"
const FeedFoundCanBeFollowErrorFallback: FC<AppErrorFallbackProps> = ({ resetError, error }) => {
if (!(error instanceof FeedFoundCanBeFollowError)) {
@ -24,65 +24,59 @@ const FeedFoundCanBeFollowErrorFallback: FC<AppErrorFallbackProps> = ({ resetErr
useResetErrorWhenRouteChange(resetError)
return (
<div className="flex w-full flex-col items-center justify-center rounded-md bg-theme-modal-background-opaque p-2">
<div className="center m-auto flex max-w-prose flex-col gap-4 text-center">
<FeedIcon feed={feed} size={60} className="rounded" />
<div className="flex w-full flex-col overflow-auto bg-theme-modal-background-opaque p-2">
<FeedPreview feedId={feed.id}>
{{
actions: (
<div className="center mt-3 gap-4">
<Button
variant="outline"
onClick={() => {
navigate("/")
setTimeout(() => {
resetError()
}, 100)
}}
>
Back
</Button>
<div className="text-lg font-bold">{feed.title}</div>
<Button
onClick={() => {
present({
title: "Add Feed",
content: ({ dismiss }) => (
<FeedForm
asWidget
url={feed.url}
defaultValues={{
view: getSidebarActiveView().toString(),
}}
onSuccess={() => {
dismiss()
<p className="my-3">{feed.description}</p>
const { feedId, view } = getRouteParams()
<div className="center gap-1 opacity-80">
<i className="i-mgc-information-cute-re" />
We found this feed in our database, you can follow it or go back to homepage.
</div>
<div className="center mt-12 gap-4">
<Button
variant="outline"
onClick={() => {
navigate("/")
setTimeout(() => {
resetError()
}, 100)
}}
>
Back
</Button>
const entriesOptions = {
id: feedId,
view,
}
<Button
onClick={() => {
present({
title: "Add Feed",
content: ({ dismiss }) => (
<FeedForm
asWidget
url={feed.url}
defaultValues={{
view: getSidebarActiveView().toString(),
}}
onSuccess={() => {
dismiss()
const { feedId, view } = getRouteParams()
const entriesOptions = {
id: feedId,
view,
}
entries.entries(entriesOptions).remove()
nextFrame(() => resetError())
}}
/>
),
})
}}
variant="primary"
>
Follow
</Button>
</div>
</div>
entries.entries(entriesOptions).remove()
nextFrame(() => resetError())
}}
/>
),
})
}}
variant="primary"
>
Follow
</Button>
</div>
),
}}
</FeedPreview>
</div>
)
}

View File

@ -1,9 +1,12 @@
import { useEffect, useRef } from "react"
import type { FC } from "react"
import { createElement, useEffect, useRef } from "react"
import { useLocation } from "react-router-dom"
import { nextFrame } from "~/lib/dom"
import { createErrorToaster } from "~/lib/error-parser"
import type { AppErrorFallbackProps } from "../common/AppErrorBoundary"
export const parseError = (error: unknown): { message?: string; stack?: string } => {
if (error instanceof Error) {
return {
@ -42,3 +45,16 @@ export const useResetErrorWhenRouteChange = (resetError: () => void) => {
}
}, [location.pathname])
}
export const withErrorGrand = <T extends Error, S extends new (...args: any[]) => T>(
error: S,
Component: FC<AppErrorFallbackProps>,
): FC<AppErrorFallbackProps> => {
return (props: AppErrorFallbackProps) => {
if (!(props.error instanceof error)) {
throw error
}
return createElement(Component, props)
}
}

View File

@ -0,0 +1,110 @@
import type { FC } from "react"
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
import { FeedCertification } from "~/components/feed-certification"
import { FeedIcon } from "~/components/feed-icon"
import { LoadingCircle } from "~/components/ui/loading"
import { views } from "~/constants"
import type { FeedViewType } from "~/lib/enum"
import { cn } from "~/lib/utils"
import type { FeedModel } from "~/models"
import { getItemComponentByView } from "~/modules/entry-column/Items"
import type { UniversalItemProps } from "~/modules/entry-column/types"
import { useEntriesPreview } from "~/queries/entries"
import { useFeed } from "~/queries/feed"
export function FeedPreview(props: {
feedId: string
children: {
actions: React.ReactNode
}
}) {
const { feedId: id, children } = props
const [search] = useSearchParams()
const view = Number.parseInt(search.get("view") || "0")
const feed = useFeed({
id,
})
const feedData = feed.data?.feed as FeedModel
const entries = useEntriesPreview({
id,
})
const Item: FC<UniversalItemProps> = getItemComponentByView(view as FeedViewType)
const { t } = useTranslation()
return (
<>
{feed.isLoading ? (
<LoadingCircle size="large" className="center absolute inset-0" />
) : (
feed.data?.feed && (
<div
className={cn(
"mx-auto mt-12 flex w-full flex-col items-center p-4 lg:p-0",
views[view].gridMode ? "max-w-full" : "max-w-prose",
)}
>
<FeedIcon
fallback
feed={feed.data.feed}
className="mask-squircle mask shrink-0"
size={64}
/>
<div className="flex flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{feed.data.feed.title}</h1>
<FeedCertification feed={feed.data.feed} />
</div>
<div className="mb-8 text-sm text-zinc-500">{feed.data.feed.description}</div>
</div>
<div className="mb-4 text-sm">
{t("feed.followsAndReads", {
subscriptionCount: feed.data.subscriptionCount,
subscriptionNoun: t("feed.follower", { count: feed.data.subscriptionCount }),
readCount: feed.data.readCount,
readNoun: t("feed.read", { count: feed.data.readCount }),
appName: APP_NAME,
})}
</div>
{children.actions}
<div
className={cn(
"w-full pb-12 pt-8",
views[view].gridMode
? "grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4"
: "flex max-w-3xl flex-col gap-6",
)}
>
{entries.data?.map((entry) => (
<a
className="relative cursor-pointer"
href={entry.url || void 0}
target="_blank"
key={entry.id}
>
<div className="rounded-xl pl-3 duration-300 hover:bg-theme-item-active">
<Item
entryPreview={{
entries: entry,
feeds: feedData,
read: true,
feedId: feedData.id!,
}}
entryId={entry.id}
/>
</div>
</a>
))}
</div>
</div>
)
)}
</>
)
}

View File

@ -53,7 +53,7 @@ export const TextArea = forwardRef<
return (
<div
className={cn(
"group relative h-full border ring-0 ring-accent/20 duration-200 [--spotlight-color:oklch(var(--a)_/_0.12)]",
"group relative h-full border ring-0 ring-accent/20 duration-200",
roundedMap[rounded],
"border-transparent",

View File

@ -1,4 +1,3 @@
/// <reference types="vite/client" />
declare const APP_VERSION: string
declare const APP_NAME: string
declare const RELEASE_CHANNEL: string

View File

@ -1,4 +1,3 @@
import type { FC, PropsWithChildren } from "react"
import type { useTranslation } from "react-i18next"
// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars
const { t } = useTranslation()
@ -7,14 +6,6 @@ const { t: settingsT } = useTranslation("settings")
// eslint-disable-next-line react-hooks/rules-of-hooks, unused-imports/no-unused-vars
const { t: shortcutsT } = useTranslation("shortcuts")
declare global {
export type Component<P = object> = FC<ComponentType & P>
export type ComponentType<P = object> = {
className?: string
} & PropsWithChildren &
P
export type Nullable<T> = T | null | undefined
// BIZ ID
export type Id = string
export type FeedId = Id
@ -33,18 +24,9 @@ declare global {
WINDOW_UNDER_BLUR: boolean
}
/**
* This function is a macro, will replace in the build stage.
*/
export function tw(strings: TemplateStringsArray, ...values: any[]): string
export type I18nKeys = OmitStringType<Parameters<typeof t>[0]>
export type I18nKeysForSettings = OmitStringType<Parameters<typeof settingsT>[0]>
export type I18nKeysForShortcuts = OmitStringType<Parameters<typeof shortcutsT>[0]>
type IsLiteralString<T> = T extends string ? (string extends T ? never : T) : never
type OmitStringType<T> = T extends any[] ? OmitStringType<T[number]> : IsLiteralString<T>
}
export {}

View File

@ -11,6 +11,7 @@ export * from "./usePrevious"
export * from "./useRefValue"
export * from "./useSetState"
export * from "./useSwitchHotkeyScope"
export * from "./useSyncTheme"
export * from "./useTitle"
export * from "./useTypescriptHappyCallback"
export * from "./useVideo"

View File

@ -0,0 +1,15 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { atom } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { useMediaQuery } from "usehooks-ts"
import { getStorageNS } from "~/lib/ns"
export const useDarkQuery = () => useMediaQuery("(prefers-color-scheme: dark)")
export type ColorMode = "light" | "dark" | "system"
export const themeAtom = !IN_ELECTRON
? atomWithStorage(getStorageNS("color-mode"), "system" as ColorMode, undefined, {
getOnInit: true,
})
: atom("system" as ColorMode)

View File

@ -1,21 +1,6 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { atom, useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { useCallback, useLayoutEffect } from "react"
import { useMediaQuery } from "usehooks-ts"
import { useAtomValue } from "jotai"
import { tipcClient } from "~/lib/client"
import { nextFrame } from "~/lib/dom"
import { jotaiStore } from "~/lib/jotai"
import { getStorageNS } from "~/lib/ns"
const useDarkQuery = () => useMediaQuery("(prefers-color-scheme: dark)")
type ColorMode = "light" | "dark" | "system"
const themeAtom = !IN_ELECTRON
? atomWithStorage(getStorageNS("color-mode"), "system" as ColorMode, undefined, {
getOnInit: true,
})
: atom("system" as ColorMode)
import { themeAtom, useDarkQuery } from "./internal/for-theme"
function useDarkWebApp() {
const systemIsDark = useDarkQuery()
@ -25,72 +10,3 @@ function useDarkWebApp() {
export const useIsDark = useDarkWebApp
export const useThemeAtomValue = () => useAtomValue(themeAtom)
const useSyncThemeElectron = () => {
const appIsDark = useDarkQuery()
useLayoutEffect(() => {
let isMounted = true
tipcClient?.getAppearance().then((appearance) => {
if (!isMounted) return
jotaiStore.set(themeAtom, appearance)
disableTransition(["[role=switch]>*"])()
document.documentElement.dataset.theme =
appearance === "system" ? (appIsDark ? "dark" : "light") : appearance
})
return () => {
isMounted = false
}
}, [appIsDark])
}
const useSyncThemeWebApp = () => {
const colorMode = useAtomValue(themeAtom)
const systemIsDark = useDarkQuery()
useLayoutEffect(() => {
const realColorMode: Exclude<ColorMode, "system"> =
colorMode === "system" ? (systemIsDark ? "dark" : "light") : colorMode
document.documentElement.dataset.theme = realColorMode
disableTransition(["[role=switch]>*"])()
}, [colorMode, systemIsDark])
}
export const useSyncThemeark = IN_ELECTRON ? useSyncThemeElectron : useSyncThemeWebApp
export const useSetTheme = () =>
useCallback((colorMode: ColorMode) => {
jotaiStore.set(themeAtom, colorMode)
if (IN_ELECTRON) {
tipcClient?.setAppearance(colorMode)
}
}, [])
function disableTransition(disableTransitionExclude: string[] = []) {
const css = document.createElement("style")
css.append(
document.createTextNode(
`
*${disableTransitionExclude.map((s) => `:not(${s})`).join("")} {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`,
),
)
document.head.append(css)
return () => {
// Force restyle
;(() => window.getComputedStyle(document.body))()
// Wait for next tick before removing
nextFrame(() => {
css.remove()
})
}
}

View File

@ -0,0 +1,79 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { useAtomValue } from "jotai"
import { useCallback, useLayoutEffect } from "react"
import { tipcClient } from "~/lib/client"
import { nextFrame } from "~/lib/dom"
import { jotaiStore } from "~/lib/jotai"
import type { ColorMode } from "./internal/for-theme"
import { themeAtom, useDarkQuery } from "./internal/for-theme"
const useSyncThemeElectron = () => {
const appIsDark = useDarkQuery()
useLayoutEffect(() => {
let isMounted = true
tipcClient?.getAppearance().then((appearance) => {
if (!isMounted) return
jotaiStore.set(themeAtom, appearance)
disableTransition(["[role=switch]>*"])()
document.documentElement.dataset.theme =
appearance === "system" ? (appIsDark ? "dark" : "light") : appearance
})
return () => {
isMounted = false
}
}, [appIsDark])
}
const useSyncThemeWebApp = () => {
const colorMode = useAtomValue(themeAtom)
const systemIsDark = useDarkQuery()
useLayoutEffect(() => {
const realColorMode: Exclude<ColorMode, "system"> =
colorMode === "system" ? (systemIsDark ? "dark" : "light") : colorMode
document.documentElement.dataset.theme = realColorMode
disableTransition(["[role=switch]>*"])()
}, [colorMode, systemIsDark])
}
export const useSyncTheme = IN_ELECTRON ? useSyncThemeElectron : useSyncThemeWebApp
export const useSetTheme = () =>
useCallback((colorMode: ColorMode) => {
jotaiStore.set(themeAtom, colorMode)
if (IN_ELECTRON) {
tipcClient?.setAppearance(colorMode)
}
}, [])
function disableTransition(disableTransitionExclude: string[] = []) {
const css = document.createElement("style")
css.append(
document.createTextNode(
`
*${disableTransitionExclude.map((s) => `:not(${s})`).join("")} {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
-ms-transition: none !important;
transition: none !important;
}
`,
),
)
document.head.append(css)
return () => {
// Force restyle
;(() => window.getComputedStyle(document.body))()
// Wait for next tick before removing
nextFrame(() => {
css.remove()
})
}
}

View File

@ -19,7 +19,7 @@ class UrlBuilderStatic {
}
profile(id: string) {
return this.join(`profile/${id}`)
return this.join(`share/users/${id}`)
}
}

View File

@ -149,6 +149,7 @@ export function ListItem({
)}
</div>
{/* TODO remove This only share page needed */}
{feedId && !isFollowed && (
<Button
onClick={(e) => {

View File

@ -42,7 +42,7 @@ export const AutoUpdater = () => {
[mouseX, mouseY, radius],
)
const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, var(--a) 0%, transparent 65%)`
const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, hsl(var(--fo-a)) 0%, transparent 65%)`
if (!updaterStatus) return null

View File

@ -1,4 +1,3 @@
import { env } from "@follow/shared/env"
import { AnimatePresence, useAnimationControls } from "framer-motion"
import { useAtom } from "jotai"
import { atomWithStorage } from "jotai/utils"
@ -24,6 +23,7 @@ import { defineQuery } from "~/lib/defineQuery"
import { nextFrame, stopPropagation } from "~/lib/dom"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { getStorageNS } from "~/lib/ns"
import { UrlBuilder } from "~/lib/url-builder"
import { cn } from "~/lib/utils"
import type { SubscriptionModel } from "~/models"
import { useUserSubscriptionsQuery } from "~/modules/profile/hooks"
@ -219,9 +219,8 @@ export const UserProfileModalContent: FC<{
<ActionButton
tooltip={t("user_profile.share")}
onClick={() => {
window.open(
`${env.VITE_WEB_URL}/profile/${user.data?.handle ? `@${user.data.handle}` : user.data?.id}`,
)
if (!user.data) return
window.open(UrlBuilder.profile(user.data.handle ?? user.data.id))
}}
>
<i className="i-mgc-share-3-cute-re" />

View File

@ -1,142 +1,11 @@
import { Fragment } from "react/jsx-runtime"
import { useParams } from "react-router-dom"
import { redirect } from "react-router-dom"
import { useWhoami } from "~/atoms/user"
import { PoweredByFooter } from "~/components/common/PoweredByFooter"
import { FeedIcon } from "~/components/feed-icon"
import { FollowIcon } from "~/components/icons/follow"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Button } from "~/components/ui/button"
import { LoadingCircle } from "~/components/ui/loading"
import { usePresentFeedFormModal } from "~/hooks/biz/useFeedFormModal"
import { useAuthQuery, useI18n, useTitle } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { UrlBuilder } from "~/lib/url-builder"
import { cn, isBizId } from "~/lib/utils"
import { useUserSubscriptionsQuery } from "~/modules/profile/hooks"
export function Component() {
const t = useI18n()
const { id } = useParams()
const isHandle = id ? id.startsWith("@") || !isBizId(id) : false
const user = useAuthQuery(
defineQuery(["profiles", id], async () => {
const res = await apiClient.profiles.$get({
query: {
handle: isHandle ? (id?.startsWith("@") ? id.slice(1) : id) : undefined,
id: isHandle ? undefined : id,
},
})
return res.data
}),
{
enabled: !!id,
},
)
const subscriptions = useUserSubscriptionsQuery(user.data?.id)
useTitle(user.data?.name)
const me = useWhoami()
const isMe = user.data?.id === me?.id
const presentFeedFormModal = usePresentFeedFormModal()
return (
<div className="container mx-auto mt-12 flex grow flex-col items-center p-4 lg:p-0">
{user.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
<Fragment>
<Avatar className="aspect-square size-16">
<AvatarImage
className="duration-200 animate-in fade-in-0"
src={replaceImgUrlIfNeed(user.data?.image || undefined)}
/>
<AvatarFallback>{user.data?.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
<div className="flex flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{user.data?.name}</h1>
</div>
<div className="mb-8 text-sm text-zinc-500">{user.data?.handle}</div>
</div>
<div
className="mb-12 w-[70ch] max-w-full grow space-y-10"
data-testid="profile-subscriptions"
>
{subscriptions.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
Object.keys(subscriptions.data || {}).map((category) => (
<div key={category}>
<div className="mb-4 flex items-center text-2xl font-bold">
<h3>{category}</h3>
</div>
<div>
{subscriptions.data?.[category].map(
(subscription) =>
"feeds" in subscription && (
<div key={subscription.feedId} className="group relative border-b py-5">
<a
className="flex flex-1 cursor-button"
href={UrlBuilder.shareFeed(subscription.feedId)}
target="_blank"
>
<FeedIcon
fallback
feed={subscription.feeds}
size={22}
className="mr-3"
/>
<div
className={cn(
"flex w-0 flex-1 grow flex-col justify-center",
"group-hover:grow-[0.85]",
)}
>
<div className="truncate font-medium leading-none">
{subscription.feeds?.title}
</div>
{subscription.feeds?.description && (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{subscription.feeds.description}
</div>
)}
</div>
</a>
<span className="center absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover:opacity-100">
<Button
onClick={(e) => {
e.stopPropagation()
presentFeedFormModal({
feedId: subscription.feedId,
})
}}
>
{isMe ? (
t.common("words.edit")
) : (
<>
<FollowIcon className="mr-1 size-3" />
{APP_NAME}
</>
)}
</Button>
</span>
</div>
),
)}
</div>
</div>
))
)}
</div>
<PoweredByFooter className="pb-12" />
</Fragment>
)}
</div>
)
export const loader = async ({ params }: { params: { id: string } }) => {
return redirect(`/share/users/${params.id}`)
}
/**
* @deprecated remove after split application
*/
export function Component() {
return null
}

View File

@ -0,0 +1,142 @@
import { Fragment } from "react/jsx-runtime"
import { useParams } from "react-router-dom"
import { useWhoami } from "~/atoms/user"
import { PoweredByFooter } from "~/components/common/PoweredByFooter"
import { FeedIcon } from "~/components/feed-icon"
import { FollowIcon } from "~/components/icons/follow"
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"
import { Button } from "~/components/ui/button"
import { LoadingCircle } from "~/components/ui/loading"
import { usePresentFeedFormModal } from "~/hooks/biz/useFeedFormModal"
import { useAuthQuery, useI18n, useTitle } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { UrlBuilder } from "~/lib/url-builder"
import { cn, isBizId } from "~/lib/utils"
import { useUserSubscriptionsQuery } from "~/modules/profile/hooks"
export function Component() {
const t = useI18n()
const { id } = useParams()
const isHandle = id ? id.startsWith("@") || !isBizId(id) : false
const user = useAuthQuery(
defineQuery(["profiles", id], async () => {
const res = await apiClient.profiles.$get({
query: {
handle: isHandle ? (id?.startsWith("@") ? id.slice(1) : id) : undefined,
id: isHandle ? undefined : id,
},
})
return res.data
}),
{
enabled: !!id,
},
)
const subscriptions = useUserSubscriptionsQuery(user.data?.id)
useTitle(user.data?.name)
const me = useWhoami()
const isMe = user.data?.id === me?.id
const presentFeedFormModal = usePresentFeedFormModal()
return (
<div className="container mx-auto mt-12 flex grow flex-col items-center p-4 lg:p-0">
{user.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
<Fragment>
<Avatar className="aspect-square size-16">
<AvatarImage
className="duration-200 animate-in fade-in-0"
src={replaceImgUrlIfNeed(user.data?.image || undefined)}
/>
<AvatarFallback>{user.data?.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
<div className="flex flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{user.data?.name}</h1>
</div>
<div className="mb-8 text-sm text-zinc-500">{user.data?.handle}</div>
</div>
<div
className="mb-12 w-[70ch] max-w-full grow space-y-10"
data-testid="profile-subscriptions"
>
{subscriptions.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
Object.keys(subscriptions.data || {}).map((category) => (
<div key={category}>
<div className="mb-4 flex items-center text-2xl font-bold">
<h3>{category}</h3>
</div>
<div>
{subscriptions.data?.[category].map(
(subscription) =>
"feeds" in subscription && (
<div key={subscription.feedId} className="group relative border-b py-5">
<a
className="flex flex-1 cursor-button"
href={UrlBuilder.shareFeed(subscription.feedId)}
target="_blank"
>
<FeedIcon
fallback
feed={subscription.feeds}
size={22}
className="mr-3"
/>
<div
className={cn(
"flex w-0 flex-1 grow flex-col justify-center",
"group-hover:grow-[0.85]",
)}
>
<div className="truncate font-medium leading-none">
{subscription.feeds?.title}
</div>
{subscription.feeds?.description && (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{subscription.feeds.description}
</div>
)}
</div>
</a>
<span className="center absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover:opacity-100">
<Button
onClick={(e) => {
e.stopPropagation()
presentFeedFormModal({
feedId: subscription.feedId,
})
}}
>
{isMe ? (
t.common("words.edit")
) : (
<>
<FollowIcon className="mr-1 size-3" />
{APP_NAME}
</>
)}
</Button>
</span>
</div>
),
)}
</div>
</div>
))
)}
</div>
<PoweredByFooter className="pb-12" />
</Fragment>
)}
</div>
)
}

View File

@ -0,0 +1,36 @@
import { useEffect, useRef } from "react"
import { useSearchParams } from "react-router-dom"
import { useFollow } from "~/hooks/biz/useFollow"
export const ExternalJumpInProvider = () => {
// ?follow=${id}&follow_type=list
const [searchParams, setSearchParams] = useSearchParams()
const follow = useFollow()
const onceRef = useRef(false)
useEffect(() => {
if (onceRef.current) {
return
}
const followId = searchParams.get("follow")
const followType = searchParams.get("follow_type")
if (followId && followType) {
follow({
id: followId,
isList: followType === "list",
})
setSearchParams((prev) => {
prev.delete("follow")
prev.delete("follow_type")
return new URLSearchParams(prev)
})
}
onceRef.current = true
}, [follow, searchParams, setSearchParams])
return null
}

View File

@ -1,5 +1,6 @@
export { ContextMenuProvider as LazyContextMenuProvider } from "../context-menu-provider"
export { ExtensionExposeProvider as LazyExtensionExposeProvider } from "../extension-expose-provider"
export { ExternalJumpInProvider as LazyExternalJumpInProvider } from "../external-jump-in-provider"
export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container"
export { ModalStackProvider as LazyModalStackProvider } from "~/components/ui/modal"
export { FeatureFlagDebugger as LazyFeatureFlagDebugger } from "~/modules/ab/providers"

View File

@ -33,3 +33,10 @@ export {
LazyLottieRenderContainer,
LazyModalStackProvider,
}
const LazyExternalJumpInProvider = lazy(() =>
import("../external-jump-in-provider").then((res) => ({
default: res.ExternalJumpInProvider,
})),
)
export { LazyExternalJumpInProvider }

View File

@ -18,6 +18,7 @@ import { InvalidateQueryProvider } from "./invalidate-query-provider"
import {
LazyContextMenuProvider,
LazyExtensionExposeProvider,
LazyExternalJumpInProvider,
LazyFeatureFlagDebugger,
LazyLottieRenderContainer,
LazyModalStackProvider,
@ -58,6 +59,8 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<LazyModalStackProvider />
<LazyContextMenuProvider />
<LazyLottieRenderContainer />
<LazyExternalJumpInProvider />
<LazyFeatureFlagDebugger />
</Suspense>
<Toaster />

View File

@ -5,7 +5,7 @@ import { useGeneralSettingKey } from "~/atoms/settings/general"
import { useUISettingValue } from "~/atoms/settings/ui"
import { I18N_LOCALE_KEY } from "~/constants"
import { useReduceMotion } from "~/hooks/biz/useReduceMotion"
import { useSyncThemeark } from "~/hooks/common"
import { useSyncTheme } from "~/hooks/common"
import { langChain } from "~/i18n"
import { tipcClient } from "~/lib/client"
import { loadLanguageAndApply } from "~/lib/load-language"
@ -13,7 +13,7 @@ import { feedUnreadActions } from "~/store/unread"
const useUISettingSync = () => {
const setting = useUISettingValue()
useSyncThemeark()
useSyncTheme()
useInsertionEffect(() => {
const root = document.documentElement
root.style.fontSize = `${setting.uiTextSize}px`

View File

@ -1,6 +0,0 @@
/// <reference types="vite/client" />
declare module "*?asset" {
const src: string
export default src
}

View File

@ -8,7 +8,6 @@
:root,
#shadow-html {
--radius: 0.5rem;
--a: theme("colors.theme.accent.DEFAULT");
}
[data-theme="light"] {

View File

@ -11,6 +11,7 @@
"noImplicitReturns": false,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client", "@follow/types/global", "@follow/types/react"],
"paths": {
"~/*": ["src/*"],
"@pkg": ["../../package.json"],

1
apps/server/.env Symbolic link
View File

@ -0,0 +1 @@
../../.env

11
apps/server/api/index.ts Normal file
View File

@ -0,0 +1,11 @@
const { createApp } = require("../dist/server/index.js")
// export const config = {
// runtime: "nodejs", // this is a pre-requisite
// }
module.exports = async function handler(req: any, res: any) {
const app = await createApp()
await app.ready()
app.server.emit("request", req, res)
}

View File

@ -0,0 +1,48 @@
// This file is auto-generated by scripts/generate-i18n-locale.ts
// DONT EDIT THIS FILE MANUALLY
export const currentSupportedLanguages = [
"en",
"de",
"ja",
"zh-CN",
"zh-TW",
"zh-HK",
"pt",
"fr",
"ar-DZ",
"ar-SA",
"ar-MA",
"ar-IQ",
"ar-KW",
"ar-TN",
"fi",
"it",
"ru",
"es",
"ko",
"tr",
]
export const dayjsLocaleImportMap = {
en: ["en", () => import("dayjs/locale/en")],
["zh-CN"]: ["zh-cn", () => import("dayjs/locale/zh-cn")],
["ja"]: ["ja", () => import("dayjs/locale/ja")],
["ru"]: ["ru", () => import("dayjs/locale/ru")],
["fi"]: ["fi", () => import("dayjs/locale/fi")],
["it"]: ["it", () => import("dayjs/locale/it")],
["ar-DZ"]: ["ar-dz", () => import("dayjs/locale/ar-dz")],
["ar-SA"]: ["ar-sa", () => import("dayjs/locale/ar-sa")],
["ar-MA"]: ["ar-ma", () => import("dayjs/locale/ar-ma")],
["es"]: ["es", () => import("dayjs/locale/es")],
["fr"]: ["fr", () => import("dayjs/locale/fr")],
["pt"]: ["pt", () => import("dayjs/locale/pt")],
["zh-TW"]: ["zh-tw", () => import("dayjs/locale/zh-tw")],
["ar-IQ"]: ["ar-iq", () => import("dayjs/locale/ar-iq")],
["ar-KW"]: ["ar-kw", () => import("dayjs/locale/ar-kw")],
["ar-TN"]: ["ar-tn", () => import("dayjs/locale/ar-tn")],
["zh-HK"]: ["zh-hk", () => import("dayjs/locale/zh-hk")],
["de"]: ["de", () => import("dayjs/locale/de")],
["ko"]: ["ko", () => import("dayjs/locale/ko")],
["tr"]: ["tr", () => import("dayjs/locale/tr")],
}
export const ns = ["common", "lang", "errors"] as const
export const defaultNS = "external" as const

View File

@ -0,0 +1,88 @@
// This file is auto-generated by scripts/generate-i18n-locale.ts
// DONT EDIT THIS FILE MANUALLY
import common_ardz from "../../../../locales/common/ar-DZ.json"
import common_ariq from "../../../../locales/common/ar-IQ.json"
import common_arkw from "../../../../locales/common/ar-KW.json"
import common_arma from "../../../../locales/common/ar-MA.json"
import common_arsa from "../../../../locales/common/ar-SA.json"
import common_artn from "../../../../locales/common/ar-TN.json"
import common_de from "../../../../locales/common/de.json"
import common_en from "../../../../locales/common/en.json"
import common_es from "../../../../locales/common/es.json"
import common_fi from "../../../../locales/common/fi.json"
import common_fr from "../../../../locales/common/fr.json"
import common_it from "../../../../locales/common/it.json"
import common_ja from "../../../../locales/common/ja.json"
import common_ko from "../../../../locales/common/ko.json"
import common_pt from "../../../../locales/common/pt.json"
import common_ru from "../../../../locales/common/ru.json"
import common_tr from "../../../../locales/common/tr.json"
import common_zhCN from "../../../../locales/common/zh-CN.json"
import common_zhHK from "../../../../locales/common/zh-HK.json"
import common_zhTW from "../../../../locales/common/zh-TW.json"
import errors_en from "../../../../locales/errors/en.json"
import external_en from "../../../../locales/external/en.json"
import lang_ardz from "../../../../locales/lang/ar-DZ.json"
import lang_ariq from "../../../../locales/lang/ar-IQ.json"
import lang_arkw from "../../../../locales/lang/ar-KW.json"
import lang_arma from "../../../../locales/lang/ar-MA.json"
import lang_arsa from "../../../../locales/lang/ar-SA.json"
import lang_artn from "../../../../locales/lang/ar-TN.json"
import lang_de from "../../../../locales/lang/de.json"
import lang_en from "../../../../locales/lang/en.json"
import lang_es from "../../../../locales/lang/es.json"
import lang_fi from "../../../../locales/lang/fi.json"
import lang_fr from "../../../../locales/lang/fr.json"
import lang_it from "../../../../locales/lang/it.json"
import lang_ja from "../../../../locales/lang/ja.json"
import lang_ko from "../../../../locales/lang/ko.json"
import lang_pt from "../../../../locales/lang/pt.json"
import lang_ru from "../../../../locales/lang/ru.json"
import lang_tr from "../../../../locales/lang/tr.json"
import lang_zhCN from "../../../../locales/lang/zh-CN.json"
import lang_zhHK from "../../../../locales/lang/zh-HK.json"
import lang_zhTW from "../../../../locales/lang/zh-TW.json"
/**
* This file is the language resource that is loaded in full when the app is initialized.
* When switching languages, the app will automatically download the required language resources,
* we will not load all the language resources to minimize the first screen loading time of the app.
* Generally, we only load english resources synchronously by default.
* In addition, we attach common resources for other languages, and the size of the common resources must be controlled.
*/
export const defaultResources = {
en: {
lang: lang_en,
common: common_en,
external: external_en,
errors: errors_en,
},
"zh-CN": {
lang: lang_zhCN,
common: common_zhCN,
},
"zh-HK": {
lang: lang_zhHK,
common: common_zhHK,
},
ja: {
lang: lang_ja,
common: common_ja,
},
ru: { lang: lang_ru, common: common_ru },
fi: { lang: lang_fi, common: common_fi },
it: { lang: lang_it, common: common_it },
"ar-DZ": { lang: lang_ardz, common: common_ardz },
"ar-SA": { lang: lang_arsa, common: common_arsa },
"ar-MA": { lang: lang_arma, common: common_arma },
"zh-TW": { lang: lang_zhTW, common: common_zhTW },
es: { lang: lang_es, common: common_es },
fr: { lang: lang_fr, common: common_fr },
pt: { lang: lang_pt, common: common_pt },
"ar-IQ": { lang: lang_ariq, common: common_ariq },
"ar-KW": { lang: lang_arkw, common: common_arkw },
"ar-TN": { lang: lang_artn, common: common_artn },
de: { lang: lang_de, common: common_de },
ko: { lang: lang_ko, common: common_ko },
tr: { lang: lang_tr, common: common_tr },
}

10
apps/server/client/@types/i18next.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
import type { defaultNS, ns } from "./constants"
import type { defaultResources as resources } from "./default-resource"
declare module "i18next" {
interface CustomTypeOptions {
ns: typeof ns
resources: (typeof resources)["en"]
defaultNS: typeof defaultNS
}
}

View File

@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom"
import { RootProviders } from "./providers/root-providers"
function App() {
return (
<RootProviders>
<Outlet />
</RootProviders>
)
}
export { App as Component }

View File

@ -0,0 +1,47 @@
import type { GeneralSettings } from "@follow/shared/interface/settings"
import { jotaiStore } from "../../lib/store"
import { createSettingAtom } from "./helper"
const createDefaultSettings = (): GeneralSettings => ({
// App
appLaunchOnStartup: false,
language: "en",
// Data control
dataPersist: true,
sendAnonymousData: true,
// view
unreadOnly: false,
// mark unread
scrollMarkUnread: true,
hoverMarkUnread: true,
renderMarkUnread: false,
// UX
// autoHideFeedColumn: true,
groupByDate: true,
// Secure
jumpOutLinkWarn: true,
})
export const {
useSettingKey: useGeneralSettingKey,
useSettingSelector: useGeneralSettingSelector,
setSetting: setGeneralSetting,
clearSettings: clearGeneralSettings,
initializeDefaultSettings: initializeDefaultGeneralSettings,
getSettings: getGeneralSettings,
useSettingValue: useGeneralSettingValue,
settingAtom: __generalSettingAtom,
} = createSettingAtom("general", createDefaultSettings)
export const subscribeShouldUseIndexedDB = (callback: (value: boolean) => void) =>
jotaiStore.sub(__generalSettingAtom, () => callback(getGeneralSettings().dataPersist))
export const generalServerSyncWhiteListKeys: (keyof GeneralSettings)[] = [
"appLaunchOnStartup",
"dataPersist",
"sendAnonymousData",
"language",
]

View File

@ -0,0 +1,97 @@
import { useRefValue } from "@follow/hooks"
import { createAtomHooks } from "@follow/utils/jotai"
import { getStorageNS } from "@follow/utils/ns"
import { useAtomValue } from "jotai"
import { atomWithStorage, selectAtom } from "jotai/utils"
import { useMemo } from "react"
export const createSettingAtom = <T extends object>(
settingKey: string,
createDefaultSettings: () => T,
) => {
const atom = atomWithStorage(getStorageNS(settingKey), createDefaultSettings(), undefined, {
getOnInit: true,
})
const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom)
const initializeDefaultSettings = () => {
const currentSettings = getSettings()
const defaultSettings = createDefaultSettings()
if (typeof currentSettings !== "object") setSettings(defaultSettings)
const newSettings = { ...defaultSettings, ...currentSettings }
setSettings(newSettings)
}
const selectAtomCacheMap = {} as Record<keyof ReturnType<typeof getSettings>, any>
const useSettingKey = <T extends keyof ReturnType<typeof getSettings>>(key: T) => {
let selectedAtom = selectAtomCacheMap[key]
if (!selectedAtom) {
selectedAtom = selectAtom(atom, (s) => s[key])
selectAtomCacheMap[key] = selectedAtom
}
return useAtomValue(selectedAtom) as ReturnType<typeof getSettings>[T]
}
const useSettingSelector = <
T extends keyof ReturnType<typeof getSettings>,
S extends ReturnType<typeof getSettings>,
R = S[T],
>(
selector: (s: S) => R,
): R => {
const stableSelector = useRefValue(selector)
return useAtomValue(
// @ts-expect-error
useMemo(() => selectAtom(atom, stableSelector.current), [stableSelector]),
)
}
const setSetting = <K extends keyof ReturnType<typeof getSettings>>(
key: K,
value: ReturnType<typeof getSettings>[K],
) => {
const updated = Date.now()
setSettings({
...getSettings(),
[key]: value,
updated,
})
}
const clearSettings = () => {
setSettings(createDefaultSettings())
}
Object.defineProperty(useSettingValue, "select", {
value: useSettingSelector,
})
return {
useSettingKey,
useSettingSelector,
setSetting,
clearSettings,
initializeDefaultSettings,
useSettingValue,
getSettings,
settingAtom: atom,
} as {
useSettingKey: typeof useSettingKey
useSettingSelector: typeof useSettingSelector
setSetting: typeof setSetting
clearSettings: typeof clearSettings
initializeDefaultSettings: typeof initializeDefaultSettings
useSettingValue: typeof useSettingValue & {
select: <T extends keyof ReturnType<() => T>>(key: T) => Awaited<T[T]>
}
getSettings: typeof getSettings
settingAtom: typeof atom
}
}

View File

@ -0,0 +1,8 @@
import type { User } from "@auth/core/types"
import { createAtomHooks } from "@follow/utils/jotai"
import { atom } from "jotai"
export const [, , useWhoami, , whoami, setWhoami] = createAtomHooks(atom<Nullable<User>>(null))
export const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] =
createAtomHooks(atom<boolean>(false))

View File

@ -0,0 +1,85 @@
import type { EntriesPreview } from "@client/query/entries"
import type { Feed } from "@client/query/feed"
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
import { TitleMarquee } from "@follow/components/ui/marquee/index.jsx"
import { cn } from "@follow/utils/utils"
import dayjs from "dayjs"
import type { FC } from "react"
import { TeleportalTakeOff } from "../layout/main/teleportal"
import { LazyImage } from "../ui/image"
export const GridList: FC<{
entries: EntriesPreview
feed: Feed
}> = ({ entries, feed }) => {
return (
<TeleportalTakeOff>
<div className="grid grid-cols-1 gap-3 px-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 lg:px-3">
{entries.map((entry) => (
<Wrapper key={entry.id} href={entry.url || entry.authorUrl || ""}>
<div className="relative -mx-1.5 -mt-1.5">
<LazyImage
src={entry.media?.[0].url}
className="aspect-video h-auto w-full shrink-0 rounded-md object-cover"
/>
</div>
<GridItemFooter feed={feed} entryId={entry.id} entryPreview={entry} />
</Wrapper>
))}
</div>
</TeleportalTakeOff>
)
}
const Wrapper: FC<{
children: React.ReactNode
href: string
wrapperClassName?: string
}> = ({ children, href, wrapperClassName }) => {
return (
<a
href={href}
rel="noopener noreferrer"
target="_blank"
className={cn(
"overflow-hidden rounded-md p-1.5 duration-200 hover:bg-muted",
wrapperClassName,
)}
>
{children}
</a>
)
}
const GridItemFooter: FC<{
feed: Feed
entryId: string
entryPreview: EntriesPreview[number]
}> = ({ feed, entryPreview }) => {
return (
<div className={"relative px-2 py-1 text-sm"}>
<div className="flex items-center">
<div className={"relative mb-1 mt-1.5 flex w-full items-center gap-1 truncate font-medium"}>
<TitleMarquee className="min-w-0 grow">{entryPreview.title}</TitleMarquee>
</div>
</div>
<div className="flex items-center gap-1 truncate text-[13px]">
<FeedIcon
fallback
className="mr-0.5 flex"
feed={feed.feed}
entry={entryPreview}
size={18}
/>
<span className={"min-w-0 truncate"}>{feed.feed.title}</span>
<span className={"text-zinc-500"}>·</span>
<span className={"text-zinc-500"}>
{dayjs
.duration(dayjs(entryPreview.publishedAt).diff(dayjs(), "minute"), "minute")
.humanize()}
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { RelativeTime } from "@follow/components/ui/datetime/index.jsx"
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import { cn } from "@follow/utils/utils"
import { memo } from "react"
import { LazyImage } from "../ui/image"
import type { UniversalItemProps } from "./types"
function NormalListItemImpl({
entryPreview,
withDetails,
}: UniversalItemProps & {
withDetails?: boolean
}) {
const entry = entryPreview
const feed = entryPreview?.feeds
if (!entry || !feed) return null
const displayTime = entry.entries.publishedAt
return (
<div className={"group relative flex gap-2 rounded-lg py-4 pl-3 pr-2 duration-200"}>
<FeedIcon feed={feed} fallback entry={entry.entries} />
<div className={"-mt-0.5 flex-1 text-sm leading-tight"}>
<div
className={cn(
"flex gap-1 text-[10px] font-bold",
"text-zinc-400 dark:text-neutral-500",
entry.collections && "text-zinc-600 dark:text-zinc-500",
)}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
{feed?.title}
</EllipsisHorizontalTextWithTooltip>
<span>·</span>
<span className="shrink-0">{!!displayTime && <RelativeTime date={displayTime} />}</span>
</div>
<div
className={cn(
"relative my-0.5 break-words",
!!entry.collections && "pr-5",
entry.entries.title ? withDetails && "font-medium" : "text-[13px]",
)}
>
{entry.entries.title}
{/* {!!entry.collections && <StarIcon className="absolute right-0 top-0" />} */}
</div>
{withDetails && (
<div className={cn("text-[13px]", "text-zinc-400 dark:text-neutral-500")}>
{entry.entries.description}
</div>
)}
</div>
{withDetails && entry.entries.media?.[0] && (
<div className="relative size-24 overflow-hidden rounded">
<LazyImage
proxy={{
width: 160,
height: 160,
}}
className="overflow-hidden rounded"
src={entry.entries.media[0].url}
height={entry.entries.media[0].height}
width={entry.entries.media[0].width}
blurhash={entry.entries.media[0].blurhash}
/>
</div>
)}
</div>
)
}
export const NormalListItem = memo(NormalListItemImpl)

View File

@ -0,0 +1,33 @@
import { noop } from "foxact/noop"
import type { Dispatch, SetStateAction } from "react"
import { createContext, useCallback, useContext } from "react"
import { createContext as createContextSelector, useContextSelector } from "use-context-selector"
export const MasonryItemWidthContext = createContext(0)
export const useMasonryItemWidth = () => useContext(MasonryItemWidthContext)
export const MasonryItemsAspectRatioContext = createContextSelector({} as Record<string, number>)
export const MasonryIntersectionContext = createContext<IntersectionObserver>(null!)
export const useMasonryItemRatio = (url: string) =>
useContextSelector(MasonryItemsAspectRatioContext, (ctx) => ctx[url])
export const MasonryItemsAspectRatioSetterContext =
createContext<Dispatch<SetStateAction<Record<string, number>>>>(noop)
export const useSetStableMasonryItemRatio = () => {
const ctx = useContext(MasonryItemsAspectRatioSetterContext)
return useCallback(
(url: string, ratio: number) => {
ctx((prev) => {
// Skip if the ratio is already set, make it stable
if (prev[url]) return prev
return { ...prev, [url]: ratio }
})
},
[ctx],
)
}

View File

@ -0,0 +1,310 @@
import {
MasonryItemsAspectRatioContext,
MasonryItemsAspectRatioSetterContext,
MasonryItemWidthContext,
} from "@client/components/items/picture-masonry-context"
import { TeleportalTakeOff } from "@client/components/layout/main/teleportal"
import { LazyImage } from "@client/components/ui/image"
import { getPreferredTitle } from "@client/lib/helper"
import type { EntriesPreview } from "@client/query/entries"
import type { Feed } from "@client/query/feed"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx"
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
import { TitleMarquee } from "@follow/components/ui/marquee/index.jsx"
import { Masonry } from "@follow/components/ui/masonry/index.jsx"
import type { EntryModel } from "@follow/models/types"
import { nextFrame } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import dayjs from "dayjs"
import { AnimatePresence, m } from "framer-motion"
import { throttle } from "lodash-es"
import type { RenderComponentProps } from "masonic"
import type { FC, PropsWithChildren } from "react"
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { PhotoProvider, PhotoView } from "react-photo-view"
import inlineStyle from "react-photo-view/dist/react-photo-view.css?raw"
import {
useMasonryItemRatio,
useMasonryItemWidth,
useSetStableMasonryItemRatio,
} from "./picture-masonry-context"
const MasonryItemFixedDimensionWrapper = (
props: PropsWithChildren<{
url: string
ratio?: number
height?: number
}>,
) => {
const { url, children, ratio } = props
const itemWidth = useMasonryItemWidth()
const itemHeight = ratio ? itemWidth * ratio : itemWidth
const stableRadio = useState(() => itemWidth / itemHeight || 1)[0]
const setItemStableRatio = useSetStableMasonryItemRatio()
const stableRadioCtx = useMasonryItemRatio(url)
useEffect(() => {
setItemStableRatio(url, stableRadio)
}, [setItemStableRatio, stableRadio, url])
const style = useMemo(
() => ({
width: itemWidth,
height: itemWidth / stableRadioCtx,
}),
[itemWidth, stableRadioCtx],
)
if (!style.height) return null
return (
<div className="relative flex h-full overflow-hidden" style={style}>
{children}
</div>
)
}
const maskStyle = {
maskImage: "linear-gradient(rgba(255, 255, 255, 0) 0%, rgb(255, 255, 255) 10px)",
}
const breakpoints = {
0: 1,
// 32rem => 32 * 16= 512
512: 2,
// 48rem => 48 * 16= 768
768: 3,
// 72rem => 72 * 16= 1152
1152: 4,
// 80rem => 80 * 16= 1280
1280: 5,
1536: 6,
1792: 7,
2048: 8,
}
const getCurrentColumn = (w: number) => {
// Initialize column count with the minimum number of columns
let columns = 1
// Iterate through each breakpoint and determine the column count
for (const [breakpoint, cols] of Object.entries(breakpoints)) {
if (w >= Number.parseInt(breakpoint)) {
columns = cols
} else {
break
}
}
return columns
}
export const PictureList: FC<{
entries: EntriesPreview
feed: Feed
}> = ({ entries, feed }) => {
const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})
const [currentItemWidth, setCurrentItemWidth] = useState(0)
const [currentColumn, setCurrentColumn] = useState(1)
const containerRef = useRef<HTMLDivElement>(null)
const [isInitLayout, setIsInitLayout] = useState(false)
const xGutter = currentColumn === 1 ? 0 : 12
const yGutter = 12
useLayoutEffect(() => {
const $warpper = containerRef.current
if (!$warpper) return
const handler = () => {
const column = getCurrentColumn($warpper.clientWidth)
setCurrentItemWidth(Math.trunc($warpper.clientWidth / column - xGutter))
setCurrentColumn(column)
nextFrame(() => {
setIsInitLayout(true)
})
}
const recal = throttle(handler, 1000 / 12)
let previousWidth = $warpper.offsetWidth
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = entry.contentRect.width
if (newWidth !== previousWidth) {
previousWidth = newWidth
recal()
}
}
})
recal()
resizeObserver.observe($warpper)
return () => {
resizeObserver.disconnect()
}
}, [xGutter])
const items = useMemo(() => {
const flattenedItems = []
const imageSrcSet = new Set<string>()
for (let i = 0; i < entries?.length || 0; i++) {
const entry = entries[i]
if (!entry.media) continue
for (let j = 0; j < entry.media?.length || 0; j++) {
const media = entry.media[j]
if (imageSrcSet.has(media.url)) continue
imageSrcSet.add(media.url)
flattenedItems.push({
url: media.url,
height: media.height,
width: media.width,
id: entry.id,
blurhash: media.blurhash,
entry,
feed,
})
}
}
return flattenedItems
}, [entries, feed])
return (
<PhotoProvider>
<TeleportalTakeOff>
<div className="relative flex min-w-0 px-2 lg:px-6">
<MemoedDangerousHTMLStyle>{inlineStyle}</MemoedDangerousHTMLStyle>
<div className="flex w-full flex-wrap" ref={containerRef}>
{isInitLayout && (
<MasonryItemWidthContext.Provider value={currentItemWidth}>
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemsAspectRatioSetterContext.Provider value={setMasonryItemsRadio}>
<div className="relative w-full">
<Masonry
items={items}
columnGutter={yGutter}
columnWidth={currentItemWidth}
columnCount={currentColumn}
overscanBy={2}
render={render}
itemKey={itemKey}
/>
</div>
</MasonryItemsAspectRatioSetterContext.Provider>
</MasonryItemsAspectRatioContext.Provider>
</MasonryItemWidthContext.Provider>
)}
</div>
</div>
</TeleportalTakeOff>
</PhotoProvider>
)
}
const itemKey = (item: { url: string }) => item.url
const render: React.ComponentType<
RenderComponentProps<{
url: string
height: number | undefined
width: number | undefined
blurhash: string | undefined
id: string
entry: EntryModel
feed: Feed
}>
> = memo(({ data }) => {
const [isHovered, setIsHovered] = useState(false)
return (
<MasonryItemFixedDimensionWrapper
url={data.url}
key={data.id}
height={data.height}
ratio={data.height && data.width ? data.height / data.width : undefined}
>
<div
className="overflow-hidden rounded-md"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<PhotoView src={data.url}>
<LazyImage
src={data.url}
height={data.height}
width={data.width}
blurhash={data.blurhash}
className="duration-200 hover:scale-105"
/>
</PhotoView>
<AnimatePresence>
{isHovered && (
<m.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute inset-x-0 -bottom-px z-[3] overflow-hidden rounded-b-md pb-1"
key="footer"
>
<div className="absolute inset-x-0 bottom-0 h-[56px]" style={maskStyle}>
<div className="absolute inset-x-0 bottom-0 h-[76px] bg-gradient-to-t from-black/80 to-transparent" />
</div>
<GridItemFooter
entry={data.entry}
feed={data.feed}
titleClassName="!text-white"
descriptionClassName="!text-white/80"
timeClassName="!text-white/60"
/>
</m.div>
)}
</AnimatePresence>
</div>
</MasonryItemFixedDimensionWrapper>
)
})
const GridItemFooter = ({
entry,
titleClassName,
descriptionClassName,
timeClassName,
feed,
}: {
entry: EntryModel
feed: Feed
titleClassName?: string
descriptionClassName?: string
timeClassName?: string
}) => {
return (
<div className={cn("relative px-2 py-1 text-sm")}>
<div className="flex items-center">
<div className={"mr-1 size-1.5 shrink-0 self-center rounded-full bg-accent duration-200"} />
<div
className={cn(
"relative mb-1 mt-1.5 flex w-full items-center gap-1 truncate font-medium",
titleClassName,
)}
>
<TitleMarquee className="min-w-0 grow">{entry.title}</TitleMarquee>
</div>
</div>
<div className="flex items-center gap-1 truncate text-[13px]">
<FeedIcon fallback className="mr-0.5 flex" feed={feed.feed} entry={entry} size={18} />
<span className={cn("min-w-0 truncate", descriptionClassName)}>
{getPreferredTitle(feed.feed)}
</span>
<span className={cn("text-zinc-500", timeClassName)}>·</span>
<span className={cn("text-zinc-500", timeClassName)}>
{dayjs.duration(dayjs(entry.publishedAt).diff(dayjs(), "minute"), "minute").humanize()}
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,9 @@
import type { CombinedEntryModel, FeedOrListRespModel } from "@follow/models/types"
export type UniversalItemProps = {
entryId: string
entryPreview?: CombinedEntryModel & {
feeds: FeedOrListRespModel
feedId: string
}
}

View File

@ -0,0 +1,109 @@
import { siteConfig } from "@client/configs"
import { openInFollowApp } from "@client/lib/helper"
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.jsx"
import { Kbd } from "@follow/components/ui/kbd/Kbd.jsx"
import { cn } from "@follow/utils/utils"
import type { MotionValue } from "framer-motion"
import { useMotionValueEvent, useScroll } from "framer-motion"
import * as React from "react"
import { useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { Link } from "react-router-dom"
const useMotionValueToState = (value: MotionValue<number>) => {
const [state, setState] = useState(value.get())
useMotionValueEvent(value, "change", (v) => setState(v))
return state
}
function Container({ className, ...props }: React.ComponentPropsWithoutRef<"div">) {
return (
<div
className={cn(
"mx-auto w-full max-w-[var(--container-max-width)] px-4 sm:px-6 lg:px-8",
className,
)}
{...props}
/>
)
}
const HeaderWrapper: Component = (props) => {
const { scrollY } = useScroll()
const scrollYState = useMotionValueToState(scrollY)
const showOverlay = scrollYState > 100
return (
<header
className={cn(
"fixed inset-x-0 top-0 z-50 flex h-[80px] w-full items-center px-4 duration-200 lg:px-10",
showOverlay && "h-[60px] border-b",
)}
>
<div
className={cn(
"absolute inset-0 transform-gpu [-webkit-backdrop-filter:saturate(180%)_blur(20px)] [backdrop-filter:saturate(180%)_blur(20px)] [backface-visibility:hidden]",
"bg-[var(--bg-opacity)] duration-200 [border-bottom:1px_solid_rgb(187_187_187_/_20%)]",
)}
style={{
opacity: showOverlay ? 1 : 0,
}}
/>
{props.children}
</header>
)
}
export const Header = () => {
const handleToApp = () => {
openInFollowApp(
"",
() => {
window.open(siteConfig.appUrl, "_blank")
},
true,
)
}
useHotkeys("l", handleToApp)
return (
<HeaderWrapper>
<Container>
<nav className="relative flex justify-between">
<div className="flex items-center md:gap-x-12">
<Link className="flex items-center gap-4" to="/">
<Logo className="h-8 w-auto" />
<p className="font-default text-xl font-semibold">{APP_NAME}</p>
</Link>
</div>
<div className="flex items-center gap-4">
<Button
className="flex size-9 rounded-full border-neutral-300 bg-transparent hover:border-zinc-400 dark:border-neutral-700 dark:hover:border-zinc-600 md:w-auto md:px-2"
variant="outline"
>
<a
href={siteConfig.repoUrl}
className="flex items-center gap-2"
target="_blank"
rel="noopener noreferrer"
>
<span className="i-simple-icons-github size-4" />
<span className="hidden md:inline">Star on GitHub</span>
</a>
</Button>
<Button
buttonClassName="hidden lg:block cursor-pointer !text-accent-content"
onClick={handleToApp}
>
Open app
<Kbd className="kbd-xs !leading-0 !dark:bg-zinc-200 ml-2 size-5 scale-[0.85] rounded-sm !border-transparent !bg-zinc-50 !font-mono text-black">
L
</Kbd>
</Button>
</div>
</nav>
</Container>
</HeaderWrapper>
)
}

View File

@ -0,0 +1,14 @@
import { cn } from "@follow/utils/utils"
export const MainContainer: Component = (props) => {
return (
<div
className={cn(
"mx-auto mt-[calc(80px+3rem)] flex size-full flex-col p-4 lg:p-0",
props.className,
)}
>
{props.children}
</div>
)
}

View File

@ -0,0 +1,23 @@
import { atom, useAtomValue, useSetAtom } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { Fragment } from "react"
import { createPortal } from "react-dom"
const portalAtoms = atom(null as HTMLElement | null)
export const TeleportalDestination = () => {
const setPortal = useSetAtom(portalAtoms)
return (
<Fragment>
<div className="flex flex-col" ref={setPortal} />
</Fragment>
)
}
export const TeleportalTakeOff: FC<PropsWithChildren> = ({ children }) => {
const portal = useAtomValue(portalAtoms)
if (!portal) return null
return createPortal(children, portal)
}

View File

@ -0,0 +1,73 @@
import { useWhoami } from "@client/atoms/user"
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.jsx"
import type { FeedOrListRespModel } from "@follow/models/types"
import { replaceImgUrlIfNeed } from "@follow/utils/img-proxy"
import { cn } from "@follow/utils/utils"
import { useTranslation } from "react-i18next"
export const FeedCertification = ({
feed,
className,
}: {
feed: FeedOrListRespModel
className?: string
}) => {
const me = useWhoami()
const { t } = useTranslation()
return (
feed.ownerUserId &&
(feed.ownerUserId === me?.id ? (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<i className={cn("i-mgc-certificate-cute-fi ml-1.5 shrink-0 text-accent", className)} />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="px-4 py-2">
<div className="flex items-center text-base font-semibold">
<i className="i-mgc-certificate-cute-fi mr-2 size-4 shrink-0 text-accent" />
{t("feed_item.claimed_feed")}
</div>
<div>{t("feed_item.claimed_by_you")}</div>
</TooltipContent>
</TooltipPortal>
</Tooltip>
) : (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<i
className={cn("i-mgc-certificate-cute-fi ml-1.5 shrink-0 text-amber-500", className)}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="px-4 py-2">
<div className="flex items-center text-base font-semibold">
<i className="i-mgc-certificate-cute-fi mr-2 shrink-0 text-amber-500" />
{t("feed_item.claimed_feed")}
</div>
<div className="mt-1 flex items-center gap-1.5">
<span>{t("feed_item.claimed_by_owner")}</span>
{feed.owner ? (
<Avatar className="inline-flex aspect-square size-5 rounded-full">
<AvatarImage src={replaceImgUrlIfNeed(feed.owner.image || undefined)} />
<AvatarFallback>{feed.owner.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
) : (
<span>{t("feed_item.claimed_by_unknown")}</span>
)}
</div>
</TooltipContent>
</TooltipPortal>
</Tooltip>
))
)
}

View File

@ -0,0 +1,69 @@
import { getImageProxyUrl } from "@follow/utils/img-proxy"
import { cn } from "@follow/utils/utils"
import * as Avatar from "@radix-ui/react-avatar"
import type { MouseEvent, PropsWithChildren } from "react"
import { forwardRef, useMemo } from "react"
import { Blurhash } from "react-blurhash"
type LazyImageProps = PropsWithChildren<{
src?: string
blurhash?: string
className?: string
height?: number
width?: number
proxy?: {
width: number
height: number
}
onClick?: (e: MouseEvent) => void
}>
export const LazyImage = forwardRef<HTMLImageElement, LazyImageProps>(
({ src, blurhash, className, height, width, proxy, onClick }, ref) => {
const nextSrc = useMemo(() => {
if (!src) return src
if (!proxy?.height && !proxy?.width) {
return src
}
return getImageProxyUrl({
url: src,
width: proxy?.width,
height: proxy?.height,
})
}, [src, proxy?.height, proxy?.width])
return (
<Avatar.Root className="relative">
<Avatar.Image
ref={ref}
src={nextSrc}
height={height}
width={width}
className={cn("size-full", className)}
onClick={onClick}
tabIndex={1}
/>
<Avatar.Fallback asChild>
<div
className={cn(
"center size-full max-w-full",
!blurhash && "bg-theme-inactive/50",
className,
)}
style={{
aspectRatio: height && width ? height / width : undefined,
width,
}}
>
{blurhash && (
<Blurhash hash={blurhash} resolutionX={32} resolutionY={32} className="!size-full" />
)}
</div>
</Avatar.Fallback>
</Avatar.Root>
)
},
)

View File

@ -0,0 +1,4 @@
export const siteConfig = {
repoUrl: "https://github.com/RSSNext/Follow",
appUrl: "https://app.follow.is",
}

View File

@ -0,0 +1 @@
export { domMax as default } from "framer-motion"

13
apps/server/client/global.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// data hydrate
// e.g. window.__HYDRATE__['feeds.$get,query:id=41223694984583197']
declare global {
interface Window {
__HYDRATE__: Record<string, any>
}
export const GIT_COMMIT_SHA: string
export const APP_VERSION: string
}
export {}

View File

@ -0,0 +1,94 @@
import { Chain } from "@follow/utils/chain"
import { EventBus } from "@follow/utils/event-bus"
import { jotaiStore } from "@follow/utils/jotai"
import { getStorageNS } from "@follow/utils/ns"
import i18next from "i18next"
import { atom } from "jotai"
import { initReactI18next } from "react-i18next"
import { defaultNS, ns } from "./@types/constants"
import { defaultResources } from "./@types/default-resource"
import { getGeneralSettings } from "./atoms/settings/general"
export const i18nAtom = atom(i18next)
export const langChain = new Chain()
export class LocaleCache {
static shared = new LocaleCache()
private getKey(lang: string) {
return getStorageNS(`locale-${lang}`)
}
get(lang: string) {
const key = this.getKey(lang)
const cache = localStorage.getItem(key)
if (!cache) return null
return JSON.parse(cache)
}
set(lang: string) {
const key = this.getKey(lang)
const mergedResources = {} as any
for (const nsKey of ns) {
const nsResources = i18next.getResourceBundle(lang, nsKey)
mergedResources[nsKey] = nsResources
}
localStorage.setItem(key, JSON.stringify(mergedResources))
}
}
export const fallbackLanguage = "en"
export const initI18n = async () => {
const i18next = jotaiStore.get(i18nAtom)
const lang = getGeneralSettings().language
const mergedResources = {
...defaultResources,
}
let cache = null as any
if (!import.meta.env.DEV) {
cache = LocaleCache.shared.get(lang)
if (cache) {
mergedResources[lang as keyof typeof mergedResources] = cache
}
}
await i18next.use(initReactI18next).init({
ns,
lng: cache ? lang : fallbackLanguage,
fallbackLng: fallbackLanguage,
defaultNS,
debug: import.meta.env.DEV,
resources: mergedResources,
})
}
if (import.meta.hot) {
import.meta.hot.on(
"i18n-update",
async ({ file, content }: { file: string; content: string }) => {
const resources = JSON.parse(content)
const i18next = jotaiStore.get(i18nAtom)
const nsName = file.match(/locales\/(.+?)\//)?.[1]
if (!nsName) return
const lang = file.split("/").pop()?.replace(".json", "")
if (!lang) return
i18next.addResourceBundle(lang, nsName, resources, true, true)
console.info("reload", lang, nsName)
await i18next.reloadResources(lang, nsName)
import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", "")
},
)
}
declare module "@follow/utils/event-bus" {
interface CustomEvent {
I18N_UPDATE: string
}
}

View File

@ -0,0 +1,22 @@
import "@follow/components/tailwind"
import "./styles/index.css"
import "@follow/components/dayjs"
// @ts-expect-error
import { ClickToComponent } from "click-to-react-component"
import * as React from "react"
import ReactDOM from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { initialize } from "./initialize"
import { router } from "./router"
const $container = document.querySelector("#root") as HTMLElement
initialize()
ReactDOM.createRoot($container).render(
<React.StrictMode>
<RouterProvider router={router} />
<ClickToComponent editor="cursor" />
</React.StrictMode>,
)

View File

@ -0,0 +1,32 @@
import { env } from "@follow/shared/env"
import type { TrackProperties } from "@openpanel/web"
import { op } from "./op"
declare global {
interface Window {
analytics?: {
capture: (event_name: string, properties?: TrackProperties | null) => void
reset: () => void
}
}
}
export const initAnalytics = () => {
if (env.VITE_OPENPANEL_CLIENT_ID === undefined) return
op.setGlobalProperties({
build: "external-web",
hash: GIT_COMMIT_SHA,
})
window.analytics = {
reset: () => {
// op.clear()
},
capture(event_name: string, properties?: TrackProperties | null) {
if (import.meta.env.DEV) return
op.track(event_name, properties as TrackProperties)
},
}
}

View File

@ -0,0 +1,21 @@
import type { User } from "@auth/core/types"
import { op } from "./op"
export const setIntegrationIdentify = async (user: User) => {
op.identify({
profileId: user.id,
email: user.email,
avatar: user.image,
lastName: user.name,
properties: {
// @ts-expect-error
handle: user.handle,
name: user.name,
},
})
await import("@sentry/react").then(({ setTag }) => {
setTag("user_id", user.id)
setTag("user_name", user.name)
})
}

View File

@ -0,0 +1,10 @@
import { initI18n } from "@client/i18n"
import { initAnalytics } from "./analytics"
import { initSentry } from "./sentry"
export const initialize = async () => {
initI18n()
initAnalytics()
initSentry()
}

View File

@ -0,0 +1,10 @@
import { env } from "@follow/shared/env"
import { OpenPanel } from "@openpanel/web"
export const op = new OpenPanel({
clientId: env.VITE_OPENPANEL_CLIENT_ID ?? "",
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
apiUrl: env.VITE_OPENPANEL_API_URL,
})

View File

@ -0,0 +1,72 @@
import { env } from "@follow/shared/env"
import type { BrowserOptions } from "@sentry/react"
import { FetchError } from "ofetch"
export const initSentry = async () => {
if (!window.SENTRY_RELEASE) return
if (import.meta.env.DEV) return
const Sentry = await import("@sentry/react")
Sentry.init({
dsn: env.VITE_SENTRY_DSN,
integrations: [
Sentry.httpClientIntegration(),
Sentry.captureConsoleIntegration({
levels: ["error"],
}),
],
...SentryConfig,
})
Sentry.setTag("appVersion", APP_VERSION)
}
const ERROR_PATTERNS = [
/Network Error/i,
/Fetch Error/i,
/XHR Error/i,
/adsbygoogle/i,
/Failed to fetch/i,
"fetch failed",
"Unable to open cursor",
"Document is not focused.",
"HTTP Client Error",
// Biz errors
"Chain aborted",
]
const SentryConfig: BrowserOptions = {
// Performance Monitoring
tracesSampleRate: 1, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ["localhost", env.VITE_API_URL],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1,
beforeSend(event, hint) {
const error = hint.originalException
if (error instanceof Error) {
const isIgnoredError = ERROR_PATTERNS.some((pattern) =>
pattern instanceof RegExp ? pattern.test(error.message) : error.message.includes(pattern),
)
if (isIgnoredError) {
return null
}
}
const isPassthroughError = [FetchError].some((errorType) => {
if (error instanceof errorType) {
return true
}
return false
})
const isAbortError = error instanceof Error && error.name === "AbortError"
if (isPassthroughError || isAbortError) {
return null
}
return event
},
}

View File

@ -0,0 +1,37 @@
import { env } from "@follow/shared/env"
import type { AppType } from "@follow/shared/hono"
import { getCsrfToken } from "@hono/auth-js/react"
import { hc } from "hono/client"
import { ofetch } from "ofetch"
let csrfTokenPromise: Promise<string> | null = null
export const apiFetch = ofetch.create({
baseURL: env.VITE_EXTERNAL_API_URL || env.VITE_API_URL,
credentials: "include",
retry: false,
onRequest: async ({ options }) => {
if (!csrfTokenPromise) {
csrfTokenPromise = getCsrfToken()
}
const csrfToken = await csrfTokenPromise
const header = new Headers(options.headers)
header.set("x-app-version", "Web External")
header.set("X-Csrf-Token", csrfToken)
options.headers = header
},
})
export const apiClient = hc<AppType>(env.VITE_EXTERNAL_API_URL || env.VITE_API_URL, {
fetch: async (input: any, options = {}) =>
apiFetch(input.toString(), options).catch((err) => {
throw err
}),
headers() {
return {
"X-App-Version": "Web External",
}
},
})

View File

@ -0,0 +1,49 @@
import type { FeedOrListRespModel } from "@follow/models/types"
import { DEEPLINK_SCHEME } from "@follow/shared/constants"
export const getPreferredTitle = (feed?: FeedOrListRespModel | null) => {
if (!feed?.id) {
return feed?.title
}
return feed.title
}
export const getHydrateData = (key: string) => {
return window.__HYDRATE__?.[key]
}
export const openInFollowApp = (
deeplink: string,
fallback: () => void,
alwaysFallback?: boolean,
): Promise<boolean> => {
return new Promise((resolve) => {
const timeout = 500
let isAppOpened = false
const handleBlur = () => {
isAppOpened = true
cleanup()
resolve(true)
}
const cleanup = () => {
window.removeEventListener("blur", handleBlur)
}
window.addEventListener("blur", handleBlur)
window.location.href = `${DEEPLINK_SCHEME}://${deeplink}`
setTimeout(() => {
cleanup()
if (!isAppOpened && !alwaysFallback) {
fallback()
resolve(false)
return
}
// TODO present a modal to the user asking if they want to open the app
}, timeout)
})
}

View File

@ -0,0 +1,23 @@
import { QueryClient } from "@tanstack/react-query"
import { FetchError } from "ofetch"
const DO_NOT_RETRY_CODES = new Set([400, 401, 403, 404, 422])
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retryDelay: 1000,
retry(failureCount, error) {
console.error(error)
if (
error instanceof FetchError &&
(error.statusCode === undefined || DO_NOT_RETRY_CODES.has(error.statusCode))
) {
return false
}
return !!(3 - failureCount)
},
},
},
})

View File

@ -0,0 +1 @@
export { jotaiStore } from "@follow/utils/jotai"

View File

@ -0,0 +1,9 @@
import { Link } from "react-router-dom"
export const Component = () => {
return (
<Link to={"/share/feeds/123"} className="text-red-500">
Hello
</Link>
)
}

View File

@ -0,0 +1,7 @@
import { useSyncThemeWebApp } from "@follow/hooks"
import { Outlet } from "react-router-dom"
export const Component = () => {
useSyncThemeWebApp()
return <Outlet />
}

View File

@ -0,0 +1,160 @@
import { GridList } from "@client/components/items/grid"
import { NormalListItem } from "@client/components/items/normal"
import { PictureList } from "@client/components/items/picture"
import { MainContainer } from "@client/components/layout/main"
import { FeedCertification } from "@client/components/ui/feed-certification"
import { openInFollowApp } from "@client/lib/helper"
import type { EntriesPreview } from "@client/query/entries"
import { useEntriesPreview } from "@client/query/entries"
import type { Feed } from "@client/query/feed"
import { useFeed } from "@client/query/feed"
import { FollowIcon } from "@follow/components/icons/follow.jsx"
import { Button } from "@follow/components/ui/button/index.jsx"
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import { cn } from "@follow/utils/utils"
import type { FC } from "react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { useParams, useSearchParams } from "react-router-dom"
import { toast } from "sonner"
const viewsRenderType = {
Normal: [
FeedViewType.Articles,
FeedViewType.Audios,
FeedViewType.Notifications,
FeedViewType.SocialMedia,
],
Picture: [FeedViewType.Pictures],
Grid: [FeedViewType.Videos],
}
const numberFormatter = new Intl.NumberFormat()
export function Component() {
const { id } = useParams()
const [search] = useSearchParams()
const view = Number.parseInt(search.get("view") || "0")
const { t } = useTranslation("external")
const feed = useFeed({
id: id!,
})
const feedData = feed.data?.feed
const isSubscribed = !!feed.data?.subscription
const entries = useEntriesPreview({
id,
})
useTitle(feed.data?.feed.title)
const renderContent = useMemo(() => {
switch (true) {
case viewsRenderType.Normal.includes(view): {
return <NormalList entries={entries.data!} feed={feed.data} />
}
case viewsRenderType.Picture.includes(view): {
return <PictureList entries={entries.data!} feed={feed.data} />
}
case viewsRenderType.Grid.includes(view): {
return <GridList entries={entries.data!} feed={feed.data} />
}
}
}, [entries.data, feed.data, view])
if (feed.isLoading || !feed.data?.feed || !feedData) {
return (
<>
<LoadingCircle size="large" className="center fixed inset-0" />
</>
)
}
return (
<MainContainer className="items-center">
<FeedIcon fallback feed={feed.data.feed} className="mask-squircle mask shrink-0" size={64} />
<div className="flex max-w-prose flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{feed.data.feed.title}</h1>
<FeedCertification feed={feed.data.feed} />
</div>
<div className="mb-8 text-sm text-zinc-500">{feed.data.feed.description}</div>
</div>
<div className="mb-4 text-sm">
{t("feed.followsAndReads", {
subscriptionCount: numberFormatter.format(feed.data.subscriptionCount),
subscriptionNoun: t("feed.follower", { count: feed.data.subscriptionCount }),
readCount: numberFormatter.format(feed.data.readCount),
readNoun: t("feed.read", { count: feed.data.readCount }),
appName: APP_NAME,
})}
</div>
<span className="center mb-8 flex gap-4">
{feedData.url.startsWith("https://") ? (
<Button
variant={"outline"}
onClick={() => {
window.open(feedData.siteUrl || feedData.url, "_blank")
}}
>
{t("feed.view_feed_url")}
</Button>
) : (
<Button
variant={"outline"}
onClick={() => {
toast.success(t("copied_link"))
navigator.clipboard.writeText(feedData.url)
}}
>
{t("feed.copy_feed_url")}
</Button>
)}
<Button
variant={isSubscribed ? "outline" : undefined}
onClick={() => {
openInFollowApp(`add?id=${id}`, () => {
window.location.href = `/feeds/${id}/pending?view=${view}`
})
}}
>
<FollowIcon className="mr-1 size-3" />
{isSubscribed ? t("feed.actions.followed") : <>{APP_NAME}</>}
</Button>
</span>
<div className={cn("w-full pb-12 pt-8", "flex max-w-3xl flex-col gap-2")}>
{entries.isLoading && !entries.data ? (
<LoadingCircle size="large" className="center mt-12" />
) : (
renderContent
)}
</div>
</MainContainer>
)
}
const NormalList: FC<{
entries: EntriesPreview
feed: Feed
}> = ({ entries, feed }) => {
return (
<>
{entries?.map((entry) => (
<a className="relative" href={entry.url || void 0} target="_blank" key={entry.id}>
<div className="rounded-xl pl-3 duration-300 hover:bg-theme-item-hover">
<NormalListItem
withDetails
entryId={entry.id}
entryPreview={{ entries: entry, feeds: feed.feed, read: true, feedId: feed.feed.id! }}
/>
</div>
</a>
))}
</>
)
}

View File

@ -0,0 +1,23 @@
import { Header } from "@client/components/layout/header"
import { TeleportalDestination } from "@client/components/layout/main/teleportal"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx"
import { PoweredByFooter } from "@follow/components/common/PoweredByFooter.jsx"
import { Outlet } from "react-router-dom"
export const Component = () => {
return (
<div className="flex h-full flex-col">
<MemoedDangerousHTMLStyle>
{`:root {
--container-max-width: 1024px;
}`}
</MemoedDangerousHTMLStyle>
<Header />
<main className="relative mx-auto w-full max-w-[var(--container-max-width)] flex-1">
<Outlet />
</main>
<TeleportalDestination />
<PoweredByFooter />
</div>
)
}

View File

@ -0,0 +1,122 @@
import { MainContainer } from "@client/components/layout/main"
import { FeedCertification } from "@client/components/ui/feed-certification"
import { openInFollowApp } from "@client/lib/helper"
import type { Feed } from "@client/query/feed"
import { useList } from "@client/query/list"
import { FollowIcon } from "@follow/components/icons/follow.jsx"
import { Button } from "@follow/components/ui/button/index.jsx"
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { useTitle } from "@follow/hooks"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { toast } from "sonner"
const numberFormatter = new Intl.NumberFormat()
export function Component() {
const { id } = useParams()
const list = useList({
id: id!,
})
const listData = list.data?.list
const isSubscribed = !!list.data?.subscription
const { t } = useTranslation("external")
const feedMap =
list.data?.list.feeds?.reduce(
(acc, feed) => {
acc[feed.id] = feed
return acc
},
{} as Record<string, Feed["feed"]>,
) || {}
useTitle(list.data?.list.title)
const handleOpenInFollowApp = () => {
openInFollowApp(`add?type=list&id=${id!}`, () => {
window.location.href = `/feeds/all/pending?view=0&follow=${id}&follow_type=list`
})
}
return (
<MainContainer>
{list.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
list.data?.list && (
<div className="mx-auto mt-12 flex w-full max-w-5xl flex-col items-center justify-center p-4 lg:p-0">
<FeedIcon
fallback
feed={list.data.list}
className="mask-squircle mask shrink-0"
size={64}
/>
<div className="flex max-w-prose flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{list.data.list.title}</h1>
<FeedCertification feed={list.data.list} />
</div>
<div className="mb-8 text-sm text-zinc-500">{list.data.list.description}</div>
</div>
<div className="mb-4 text-sm">
{t("feed.followsAndFeeds", {
subscriptionCount: numberFormatter.format(list.data?.subscriptionCount),
subscriptionNoun: t("feed.follower", { count: list.data?.subscriptionCount }),
feedsCount: numberFormatter.format(list.data?.feedCount || 0),
feedsNoun: t("feed.feeds", { count: listData?.feedIds?.length }),
appName: APP_NAME,
})}
</div>
<span className="center mb-8 flex gap-4">
<Button
variant={"outline"}
onClick={() => {
toast.success(t("copied_link"))
navigator.clipboard.writeText(id!)
}}
>
Copy List ID
</Button>
<Button
variant={isSubscribed ? "outline" : undefined}
onClick={handleOpenInFollowApp}
>
<FollowIcon className="mr-2 size-3" />
{isSubscribed ? "Followed" : <>{APP_NAME}</>}
</Button>
</span>
<div className="flex w-full max-w-3xl flex-col gap-4 pb-12 pt-8">
{listData!.feedIds
?.slice(0, 5)
.map((feedId) => <FeedRow feed={feedMap[feedId]} key={feedId} />)}
{"feedCount" in list.data && (
<div onClick={handleOpenInFollowApp} className="text-sm text-zinc-500">
{t("feed.follow_to_view_all", {
count: list.data.feedCount || 0,
})}
</div>
)}
</div>
</div>
)
)}
</MainContainer>
)
}
const FeedRow = ({ feed }: { feed: Feed["feed"] }) => {
return (
<a
className="relative flex cursor-pointer items-center text-base"
href={`/share/feeds/${feed.id}`}
target="_blank"
key={feed.id}
>
<FeedIcon fallback feed={feed} className="mask-squircle mask mr-2 shrink-0" size={20} />
{feed.title}
<FeedCertification feed={feed} />
</a>
)
}

View File

@ -0,0 +1,128 @@
import { useWhoami } from "@client/atoms/user"
import { MainContainer } from "@client/components/layout/main"
import { openInFollowApp } from "@client/lib/helper"
import { useUserQuery, useUserSubscriptionsQuery } from "@client/query/users"
import { PoweredByFooter } from "@follow/components/common/PoweredByFooter.jsx"
import { FollowIcon } from "@follow/components/icons/follow.jsx"
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import { Button } from "@follow/components/ui/button/index.jsx"
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { useTitle } from "@follow/hooks"
import { replaceImgUrlIfNeed } from "@follow/utils/img-proxy"
import { UrlBuilder } from "@follow/utils/url-builder"
import { cn } from "@follow/utils/utils"
import { Fragment } from "react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
export const Component = () => {
const params = useParams()
const user = useUserQuery(params.id)
const subscriptions = useUserSubscriptionsQuery(user.data?.id)
useTitle(user.data?.name)
const me = useWhoami()
const isMe = user.data?.id === me?.id
const { t } = useTranslation("common")
return (
<MainContainer className="items-center">
{user.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
<Fragment>
<Avatar className="aspect-square size-16">
<AvatarImage
className="duration-200 animate-in fade-in-0"
src={replaceImgUrlIfNeed(user.data?.image || undefined)}
/>
<AvatarFallback>{user.data?.name?.slice(0, 2)}</AvatarFallback>
</Avatar>
<div className="flex flex-col items-center">
<div className="mb-2 mt-4 flex items-center text-2xl font-bold">
<h1>{user.data?.name}</h1>
</div>
{user.data?.handle && (
<div className="mb-8 text-sm text-zinc-500">@{user.data.handle}</div>
)}
</div>
<div
className="mb-12 w-[70ch] max-w-full grow space-y-10"
data-testid="profile-subscriptions"
>
{subscriptions.isLoading ? (
<LoadingCircle size="large" className="center fixed inset-0" />
) : (
Object.keys(subscriptions.data || {}).map((category) => (
<div key={category}>
<div className="mb-4 flex items-center text-2xl font-bold">
<h3>{category}</h3>
</div>
<div>
{subscriptions.data?.[category].map(
(subscription) =>
"feeds" in subscription && (
<div key={subscription.feedId} className="group relative border-b py-5">
<a
className="flex flex-1 cursor-button"
href={UrlBuilder.shareFeed(subscription.feedId)}
target="_blank"
>
<FeedIcon
fallback
feed={subscription.feeds}
size={22}
className="mr-3"
/>
<div
className={cn(
"flex w-0 flex-1 grow flex-col justify-center",
"group-hover:grow-[0.85]",
)}
>
<div className="truncate font-medium leading-none">
{subscription.feeds?.title}
</div>
{subscription.feeds?.description && (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{subscription.feeds.description}
</div>
)}
</div>
</a>
<span className="center absolute inset-y-0 right-0 opacity-0 transition-opacity group-hover:opacity-100">
<Button
onClick={(e) => {
e.stopPropagation()
openInFollowApp(`add?id=${subscription.feedId}`, () => {
window.location.href = `/feeds/${subscription.feedId}/pending?view=${subscription.view}`
})
}}
>
{isMe ? (
t("words.edit")
) : (
<>
<FollowIcon className="mr-1 size-3" />
{APP_NAME}
</>
)}
</Button>
</span>
</div>
),
)}
</div>
</div>
))
)}
</div>
<PoweredByFooter className="pb-12" />
</Fragment>
)}
</MainContainer>
)
}

View File

@ -0,0 +1,39 @@
import { EventProvider } from "@follow/components/providers/event-provider.jsx"
import { StableRouterProvider } from "@follow/components/providers/stable-router-provider.jsx"
import { Toaster } from "@follow/components/ui/toast/index.jsx"
import { QueryClientProvider } from "@tanstack/react-query"
import { LazyMotion, MotionConfig } from "framer-motion"
import { Provider } from "jotai"
import { ModalStackContainer } from "rc-modal-sheet/m"
import { MobileDetector } from "rc-modal-sheet/mobile-detector"
import type { FC, PropsWithChildren } from "react"
import { queryClient } from "../lib/query-client"
import { jotaiStore } from "../lib/store"
import { UserProvider } from "./user-provider"
const loadFeatures = () => import("../framer-lazy-feature").then((res) => res.default)
export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<LazyMotion features={loadFeatures} strict key="framer">
<MotionConfig
transition={{
type: "tween",
duration: 0.15,
ease: "easeInOut",
}}
>
<Provider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<EventProvider />
<StableRouterProvider />
<ModalStackContainer>
<MobileDetector />
<UserProvider />
<Toaster />
{children}
</ModalStackContainer>
</QueryClientProvider>
</Provider>
</MotionConfig>
</LazyMotion>
)

View File

@ -0,0 +1,17 @@
import { setWhoami } from "@client/atoms/user"
import { setIntegrationIdentify } from "@client/initialize/helper"
import { useSession } from "@client/query/auth"
import { useEffect } from "react"
export const UserProvider = () => {
const { session } = useSession()
useEffect(() => {
if (!session?.user) return
setWhoami(session.user)
setIntegrationIdentify(session.user)
}, [session?.user])
return null
}

View File

@ -0,0 +1,73 @@
import type { Session } from "@auth/core/types"
import type { GetSessionParams } from "@hono/auth-js/react"
import { authConfigManager } from "@hono/auth-js/react"
import { useQuery } from "@tanstack/react-query"
import type { FetchError } from "ofetch"
import { ofetch } from "ofetch"
export const useSession = (options?: { enabled?: boolean }) => {
const { data, isLoading, ...rest } = useQuery({
queryKey: ["auth", "session"],
queryFn: () => getSession(),
retry(failureCount, error) {
const fetchError = error as FetchError
if (fetchError.statusCode === undefined) {
return false
}
return !!(3 - failureCount)
},
enabled: options?.enabled ?? true,
meta: {
persist: true,
},
})
const { error } = rest
const fetchError = error as FetchError
return {
session: data,
...rest,
status: isLoading
? "loading"
: data
? "authenticated"
: fetchError?.statusCode === 401
? "unauthenticated"
: "error",
}
}
/**
* Fetch session data, copy and patch code from @hono/auth-js/react
*/
async function fetchData<T = any>(
path: string,
req: any = {},
): Promise<T | null> {
const config = authConfigManager.getConfig()
const url = `${config.baseUrl}${config.basePath}/${path}`
const options: RequestInit = {
headers: {
"Content-Type": "application/json",
...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}),
},
credentials: config.credentials,
}
if (req?.body) {
options.body = JSON.stringify(req.body)
options.method = "POST"
}
const data = await ofetch(url, options)
return data as T
}
function getSession(params?: GetSessionParams) {
return fetchData<Session>("session", params)
}

View File

@ -0,0 +1,20 @@
import { apiClient } from "@client/lib/api-fetch"
import { useQuery } from "@tanstack/react-query"
const fetchEntriesPreview = async ({ id }: { id?: string }) => {
const res = await apiClient.entries.preview.$get({
query: {
id: id!,
},
})
return res.data
}
export const useEntriesPreview = ({ id }: { id?: string }) =>
useQuery({
queryKey: ["entries-preview", id],
queryFn: () => fetchEntriesPreview({ id }),
enabled: !!id,
})
export type EntriesPreview = Awaited<ReturnType<typeof fetchEntriesPreview>>

View File

@ -0,0 +1,22 @@
import { apiClient } from "@client/lib/api-fetch"
import { getHydrateData } from "@client/lib/helper"
import { useQuery } from "@tanstack/react-query"
async function fetchFeedById(id: string) {
const res = await apiClient.feeds.$get({
query: {
id,
},
})
return res.data
}
export const useFeed = ({ id }: { id: string }) =>
useQuery({
queryKey: ["feed", id],
queryFn: () => fetchFeedById(id),
initialData: getHydrateData(`feeds.$get,query:id=${id}`) as any as Awaited<
ReturnType<typeof fetchFeedById>
>,
})
export type Feed = Awaited<ReturnType<typeof fetchFeedById>>

View File

@ -0,0 +1,17 @@
import { apiClient } from "@client/lib/api-fetch"
import { getHydrateData } from "@client/lib/helper"
import { useQuery } from "@tanstack/react-query"
const fetchListById = async (id: string) => {
const res = await apiClient.lists.$get({ query: { listId: id } })
return res.data
}
export const useList = ({ id }: { id?: string }) =>
useQuery({
queryKey: ["lists", id],
queryFn: () => fetchListById(id!),
enabled: !!id,
initialData: getHydrateData(`lists.$get,query:listId=${id}`) as any as Awaited<
ReturnType<typeof fetchListById>
>,
})

View File

@ -0,0 +1,57 @@
import { apiClient } from "@client/lib/api-fetch"
import { getHydrateData } from "@client/lib/helper"
import { capitalizeFirstLetter, isBizId, parseUrl } from "@follow/utils/utils"
import { useQuery } from "@tanstack/react-query"
export const useUserSubscriptionsQuery = (userId: string | undefined) => {
return useQuery({
queryKey: ["subscriptions", "group", userId],
queryFn: async () => {
const res = await apiClient.subscriptions.$get({
query: { userId },
})
const groupFolder = {} as Record<string, typeof res.data>
for (const subscription of res.data || []) {
if (!subscription.category && "feeds" in subscription) {
const { siteUrl } = subscription.feeds
if (!siteUrl) continue
const parsed = parseUrl(siteUrl)
parsed.domain && (subscription.category = capitalizeFirstLetter(parsed.domain))
}
if (subscription.category) {
if (!groupFolder[subscription.category]) {
groupFolder[subscription.category] = []
}
groupFolder[subscription.category].push(subscription)
}
}
return groupFolder
},
enabled: !!userId,
})
}
export const fetchUser = async (handleOrId: string | undefined) => {
const handle = isBizId(handleOrId || "")
? handleOrId
: `${handleOrId}`.startsWith("@")
? `${handleOrId}`.slice(1)
: handleOrId
const res = await apiClient.profiles.$get({
query: {
handle,
id: isBizId(handle || "") ? handle : undefined,
},
})
return res.data
}
export const useUserQuery = (handleOrId: string | undefined) => {
return useQuery({
queryKey: ["profiles", handleOrId],
queryFn: () => fetchUser(handleOrId),
enabled: !!handleOrId,
initialData: getHydrateData(`profiles.$get,query:id=${handleOrId}`),
})
}

View File

@ -0,0 +1,39 @@
import { buildGlobRoutes } from "@follow/utils/route-builder"
import { wrapCreateBrowserRouter } from "@sentry/react"
import { createBrowserRouter, createHashRouter } from "react-router-dom"
// import { ErrorElement } from "./components/common/ErrorElement"
// import { NotFound } from "./components/common/NotFound"
const globTree = import.meta.glob("./pages/**/*.tsx")
const tree = buildGlobRoutes(globTree)
declare global {
interface Window {
SENTRY_RELEASE: string
__DEBUG_PROXY__: boolean
}
}
let routerCreator = window["__DEBUG_PROXY__"] ? createHashRouter : createBrowserRouter
if (window.SENTRY_RELEASE) {
routerCreator = wrapCreateBrowserRouter(routerCreator)
}
export const router = routerCreator(
[
{
path: "/",
lazy: () => import("./App"),
children: tree,
// errorElement: <ErrorElement />,
},
// {
// path: "*",
// element: <NotFound />,
// },
],
{
// todo
// basename: "/test",
},
)

View File

@ -0,0 +1,32 @@
@import "@fontsource/sn-pro/200-italic.css";
@import "@fontsource/sn-pro/200.css";
@import "@fontsource/sn-pro/300-italic.css";
@import "@fontsource/sn-pro/300.css";
@import "@fontsource/sn-pro/400-italic.css";
@import "@fontsource/sn-pro/400.css";
@import "@fontsource/sn-pro/500-italic.css";
@import "@fontsource/sn-pro/500.css";
@import "@fontsource/sn-pro/600-italic.css";
@import "@fontsource/sn-pro/600.css";
@import "@fontsource/sn-pro/700-italic.css";
@import "@fontsource/sn-pro/700.css";
@import "@fontsource/sn-pro/800-italic.css";
@import "@fontsource/sn-pro/800.css";
@import "@fontsource/sn-pro/900-italic.css";
@import "@fontsource/sn-pro/900.css";
button {
--cursor-button: pointer;
}
::selection {
@apply bg-accent text-white;
}
:root {
--bg-opacity: rgba(255, 255, 255, 0.72);
}
[data-theme="dark"] {
--bg-opacity: rgba(29, 29, 31, 0.72);
}

54
apps/server/index.html Normal file
View File

@ -0,0 +1,54 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="referrer" content="no-referrer" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0"
/>
<meta name="theme-color" content="#ff5c00" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<title><!-- TITLE --></title>
<!-- SSG-META -->
<link rel="manifest" href="/manifest.json" />
<script>
function setTheme() {
let e = "follow:color-mode",
t = document.documentElement,
a = localStorage.getItem(e)
function h() {
return window.matchMedia
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: void 0
: void 0
}
if (!a) {
t.dataset.theme = h() || "light"
return
}
switch ((a = JSON.parse(a))) {
case "dark":
t.dataset.theme = "dark"
break
case "light":
t.dataset.theme = "light"
break
case "system":
t.dataset.theme = h() || "light"
}
}
setTheme()
</script>
</head>
<body class="flex h-full flex-col">
<div id="root" class="flex h-full flex-col"></div>
</body>
<script type="module" src="/client/index.tsx"></script>
</html>

50
apps/server/index.ts Normal file
View File

@ -0,0 +1,50 @@
import "./src/lib/load-env"
import middie from "@fastify/middie"
import { fastifyRequestContext } from "@fastify/request-context"
import type { FastifyRequest } from "fastify"
import Fastify from "fastify"
import { isDev } from "~/lib/env"
import { globalRoute } from "./src/router/global"
import { ogRoute } from "./src/router/og"
const isVercel = process.env.VERCEL === "1"
declare module "@fastify/request-context" {
interface RequestContextData {
req: FastifyRequest
}
}
export const createApp = async () => {
const app = Fastify({})
app.register(fastifyRequestContext)
await app.register(middie, {
hook: "onRequest",
})
app.addHook("onRequest", (req, reply, done) => {
req.requestContext.set("req", req)
done()
})
if (isDev) {
const devVite = require("./src/lib/dev-vite")
await devVite.registerDevViteServer(app)
}
ogRoute(app)
globalRoute(app)
return app
}
if (!isVercel) {
createApp().then(async (app) => {
await app.listen({ port: 2234 })
console.info("Server is running on http://localhost:2234")
})
}

14
apps/server/note.md Normal file
View File

@ -0,0 +1,14 @@
Rewrite:
Test url:
https://css-spring-generator.vercel.app/share/feeds/1
https://css-spring-generator.vercel.app/og/feed/1
http://localhost:2234/share/feeds/41223694984583197
http://localhost:2234/share/feeds/41375451836487680?view=2
http://localhost:2234/share/feeds/41147805276726317?view=2
http://localhost:2234/og/list/61046160274909184
http://localhost:2234/og/user/Innei
http://localhost:2234/og/feed/41223694984583170

57
apps/server/package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "@follow/server",
"engines": {
"node": "20.x"
},
"scripts": {
"build": "NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsup && tsx scripts/cleanup-vercel-build.ts",
"dev": "NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
"start": "tsx index.ts"
},
"dependencies": {
"@fastify/middie": "9.0.2",
"@fastify/request-context": "6.0.1",
"@follow/components": "workspace:*",
"@follow/constants": "workspace:*",
"@follow/hooks": "workspace:*",
"@follow/models": "workspace:*",
"@follow/shared": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@fontsource/sn-pro": "5.1.0",
"@openpanel/web": "1.0.1",
"@radix-ui/react-avatar": "1.1.1",
"@resvg/resvg-js": "2.6.2",
"@sentry/react": "8.34.0",
"@tanstack/react-query": "5.59.14",
"blurhash": "2.0.5",
"click-to-react-component": "1.1.0",
"dayjs": "1.11.13",
"fastify": "5.0.0",
"framer-motion": "11.11.9",
"i18next": "^23.16.0",
"jotai": "2.10.1",
"kose-font": "1.0.0",
"linkedom": "^0.18.5",
"ofetch": "1.4.1",
"rc-modal-sheet": "0.3.2",
"react": "^18.3.1",
"react-blurhash": "^0.3.0",
"react-hotkeys-hook": "4.5.1",
"react-i18next": "^15.0.3",
"react-photo-view": "1.2.6",
"react-router-dom": "6.27.0",
"satori": "0.11.2",
"sonner": "^1.5.0",
"swr": "2.2.5",
"uniqolor": "1.1.1",
"use-context-selector": "2.0.0"
},
"devDependencies": {
"daisyui": "4.12.13",
"lightningcss": "1.27.0",
"tsup": "8.3.0",
"tsx": "4.19.1",
"vite": "5.4.9"
}
}

View File

@ -0,0 +1,8 @@
module.exports = {
plugins: {
tailwindcss: {},
"tailwindcss/nesting": {},
...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}),
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1 @@
<svg viewBox="0 0 145 145" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="mask0_54_296" maskUnits="userSpaceOnUse" x="0" y="0" width="145" height="145"><path fill-rule="evenodd" clip-rule="evenodd" d="M144.491 45.4541C144.491 43.75 144.492 42.0456 144.481 40.3413C144.473 38.9056 144.456 37.4702 144.417 36.0352C144.333 32.9075 144.148 29.753 143.592 26.6601C143.028 23.5227 142.107 20.6027 140.655 17.752C139.228 14.9501 137.364 12.3862 135.139 10.1633C132.915 7.94053 130.351 6.07747 127.548 4.65117C124.694 3.19908 121.771 2.27818 118.63 1.71424C115.537 1.15884 112.381 0.974699 109.254 0.890204C107.818 0.851354 106.382 0.834978 104.945 0.826093C103.24 0.81564 101.535 0.816511 99.8296 0.816511L80.0305 0.745605H65.2222L45.7734 0.816511C44.0649 0.816511 42.3564 0.81564 40.6479 0.826093C39.2085 0.834978 37.7698 0.851354 36.331 0.890204C33.196 0.974699 30.0335 1.15902 26.933 1.71511C23.7879 2.27887 20.8604 3.19943 18.0029 4.65047C15.194 6.07694 12.6239 7.94018 10.3953 10.1633C8.1671 12.386 6.29934 14.9494 4.86956 17.7508C3.41382 20.6028 2.49083 23.5246 1.92533 26.6638C1.36854 29.7554 1.18405 32.9089 1.09921 36.0352C1.06053 37.4704 1.04381 38.9057 1.03509 40.3413C1.02464 42.0458 0.937012 44.163 0.937012 45.8674L0.937534 65.0468L0.937012 80.0136L1.02551 99.6005C1.02551 101.307 1.02482 103.014 1.03509 104.72C1.04381 106.158 1.06053 107.595 1.09938 109.032C1.18405 112.164 1.36889 115.323 1.9262 118.42C2.49153 121.562 3.41434 124.486 4.86886 127.34C6.29882 130.146 8.16692 132.713 10.3953 134.939C12.6237 137.165 15.1932 139.031 18.0015 140.459C20.8607 141.913 23.7896 142.835 26.9366 143.4C30.0357 143.956 33.1972 144.141 36.331 144.225C37.7698 144.264 39.2087 144.281 40.648 144.289C42.3566 144.3 44.0649 144.299 45.7734 144.299L65.398 144.3H80.243L99.8296 144.299C101.535 144.299 103.24 144.3 104.945 144.289C106.382 144.281 107.818 144.264 109.254 144.225C112.383 144.141 115.539 143.956 118.634 143.399C121.773 142.835 124.694 141.913 127.547 140.46C130.35 139.031 132.915 137.166 135.139 134.939C137.363 132.714 139.227 130.147 140.654 127.341C142.107 124.486 143.028 121.56 143.593 118.416C144.148 115.321 144.333 112.163 144.417 109.032C144.456 107.595 144.473 106.158 144.481 104.72C144.492 103.014 144.491 101.307 144.491 99.6005C144.491 99.6005 144.49 80.3594 144.49 80.0136V65.0311C144.49 64.7757 144.491 45.4541 144.491 45.4541Z" fill="white"></path></mask><g mask="url(#mask0_54_296)"><rect x="0.414551" y="-6.74585" width="149.477" height="158.537" fill="#FF5C00"></rect><path d="M103.609 36.6948H53.5823C47.5327 36.6948 42.6284 41.5946 42.6284 47.6388C42.6284 53.683 47.5327 58.5828 53.5823 58.5828H103.609C109.659 58.5828 114.563 53.683 114.563 47.6388C114.563 41.5946 109.659 36.6948 103.609 36.6948Z" fill="white"></path><path d="M76.9348 65.1489H43.5916C37.5419 65.1489 32.6377 70.0487 32.6377 76.0929C32.6377 82.1371 37.5419 87.0369 43.5916 87.0369H76.9348C82.9845 87.0369 87.8888 82.1371 87.8888 76.0929C87.8888 70.0487 82.9845 65.1489 76.9348 65.1489Z" fill="white"></path><path d="M80.1608 104.571C80.1608 98.5272 75.2565 93.6274 69.2068 93.6274C63.1572 93.6274 58.2529 98.5272 58.2529 104.571C58.2529 110.616 63.1572 115.515 69.2068 115.515C75.2565 115.515 80.1608 110.616 80.1608 104.571Z" fill="white"></path></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,16 @@
{
"theme_color": "#ff5c00",
"name": "Follow",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@ -0,0 +1,10 @@
import { rmSync, writeFileSync } from "node:fs"
import { resolve } from "node:path"
rmSync(resolve(__dirname, "../.generated"), { recursive: true, force: true })
// restore env file
writeFileSync(
resolve(__dirname, "../src/lib/env.ts"),
`export const isDev = process.env.NODE_ENV === "development"`,
)

View File

@ -0,0 +1,55 @@
import { mkdirSync } from "node:fs"
import fs from "node:fs/promises"
import path from "node:path"
// const fontDepsPath = require.resolve("@fontsource/sn-pro")
// const fontsDirPath = resolve(fontDepsPath, "../files")
// const fontsDir = readdirSync(fontsDirPath).filter(
// (name) => !name.endsWith(".woff2") && !name.includes("italic"),
// )
mkdirSync(path.join(__dirname, "../.generated"), { recursive: true })
// async function generateFontData() {
// const files = {} as Record<string, string>
// await Promise.all(
// fontsDir.map(async (file) => {
// const r = await fs.readFile(path.join(fontsDirPath, file))
// const base64 = r.toString("base64")
// files[file] = base64
// }),
// )
// writeFileSync(
// path.join(__dirname, "../.generated/fonts-data.ts"),
// `export default ${JSON.stringify(files, null, 2)}`,
// )
// }
async function generateIndexHtmlData() {
const indexHtml = await fs.readFile(path.join(__dirname, "../dist/index.html"), "utf-8")
await fs.writeFile(
path.join(__dirname, "../.generated/index.template.ts"),
`export default ${JSON.stringify(indexHtml)}`,
)
}
async function replaceEnvFile() {
const envFile = await fs.readFile(path.join(__dirname, "../src/lib/env.ts"), "utf-8")
await fs.writeFile(
path.join(__dirname, "../src/lib/env.ts"),
// For tree shaking
envFile.replace(
`export const isDev = process.env.NODE_ENV === "development"`,
`export const isDev = ${process.env.NODE_ENV === "development"}`,
),
)
}
async function main() {
await Promise.all([generateIndexHtmlData(), replaceEnvFile()])
}
main()

1
apps/server/src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare const __DEV__: boolean

View File

@ -0,0 +1,63 @@
import "./load-env"
import { requestContext } from "@fastify/request-context"
import { env } from "@follow/shared/env"
import type { AppType } from "@follow/shared/hono"
import { hc } from "hono/client"
import { ofetch } from "ofetch"
import PKG from "../../../../package.json"
import { isDev } from "./env"
export const createApiClient = (authSessionToken: string) => {
const req = requestContext.get("req")!
const { host } = req.headers
let baseURL = env.VITE_EXTERNAL_API_URL || env.VITE_API_URL
if (env.VITE_EXTERNAL_API_URL?.startsWith("/")) {
baseURL = `http://${host}${env.VITE_EXTERNAL_API_URL}`
}
const apiFetch = ofetch.create({
// baseURL,
credentials: "include",
retry: false,
onRequestError(context) {
if (context.error.name === "AbortError") {
return
}
},
})
const apiClient = hc<AppType>(baseURL, {
fetch: async (input: any, options = {}) => apiFetch(input.toString(), options),
headers() {
return {
"X-App-Version": PKG.version,
"X-App-Dev": isDev ? "1" : "0",
Cookie: authSessionToken ? `authjs.session-token=${authSessionToken}` : "",
}
},
})
return apiClient
}
export const getTokenFromCookie = (cookie: string) => {
const parsedCookieMap = cookie
.split(";")
.map((item) => item.trim())
.reduce(
(acc, item) => {
const [key, value] = item.split("=")
acc[key] = value
return acc
},
{} as Record<string, string>,
)
return parsedCookieMap["authjs.session-token"]
}
export type ApiClient = ReturnType<typeof createApiClient>

View File

@ -0,0 +1,27 @@
import { resolve } from "node:path"
import type { FastifyInstance } from "fastify"
import type { ViteDevServer } from "vite"
import { createServer as createViteServer } from "vite"
const root = resolve(__dirname, "../..")
let globalVite: ViteDevServer
export const registerDevViteServer = async (app: FastifyInstance) => {
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "custom",
configFile: resolve(root, "vite.config.mts"),
envDir: root,
})
globalVite = vite
// @ts-ignore
app.use(vite.middlewares)
return vite
}
export const getViteServer = () => {
return globalVite
}

View File

@ -0,0 +1 @@
export const isDev = process.env.NODE_ENV === "development"

View File

@ -0,0 +1,7 @@
import { resolve } from "node:path"
import { config } from "dotenv"
config({
path: resolve(__dirname, "../../.env"),
})

View File

@ -0,0 +1,104 @@
import fs from "node:fs"
import path, { resolve } from "node:path"
const weights = [
{
name: "Thin",
weight: 100,
},
{
name: "ExtraLight",
weight: 200,
},
{
name: "Light",
weight: 300,
},
{
name: "Regular",
weight: 400,
},
{
name: "Italic",
weight: 400,
},
{
name: "Medium",
weight: 500,
},
{
name: "SemiBold",
weight: 600,
},
{
name: "Bold",
weight: 700,
},
{
name: "ExtraBold",
weight: 800,
},
{
name: "Black",
weight: 900,
},
] as const
let fontsData = [] as any[]
const snFontDepsPath = require.resolve("@fontsource/sn-pro")
const snFontsDirPath = resolve(snFontDepsPath, "../files")
const snFontsDir = fs
.readdirSync(snFontsDirPath)
.filter((name) => !name.endsWith(".woff2") && !name.includes("italic"))
fontsData = snFontsDir.map((file) => {
const weight = weights.find((weight) => file.includes(weight.weight.toString()))
if (!weight) {
return null
}
return {
name: `SN Pro`,
data: fs.readFileSync(path.join(snFontsDirPath, file)),
weight: weight.weight,
style: file.includes("Italic") ? "italic" : ("normal" as "italic" | "normal"),
}
})
const koseFontPath = require.resolve("kose-font")
const koseFontData = fs.readFileSync(koseFontPath)
fontsData.push({
name: "Kose",
data: koseFontData,
weight: 400,
style: "normal" as "italic" | "normal",
})
// if (isDev) {
// const fontDepsPath = require.resolve("@fontsource/sn-pro")
// const fontsDirPath = resolve(fontDepsPath, "../files")
// const fontsDir = fs
// .readdirSync(fontsDirPath)
// .filter((name) => !name.endsWith(".woff2") && !name.includes("italic"))
// fontsData = fontsDir.map((file) => ({
// name: file.split("-")[0],
// data: fs.readFileSync(path.join(fontsDirPath, file)),
// weight: weights.find((weight) => weight.name === file.split("-")[1])?.weight,
// style: file.includes("Italic") ? "italic" : ("normal" as "italic" | "normal"),
// }))
// } else {
// const { default: fontsBase64Data } = require("../../.generated/fonts-data")
// for (const fontName in fontsBase64Data) {
// fontsData.push({
// name: fontName.split("-")[0],
// weight: weights.find((weight) => weight.name === fontName.split("-")[1])?.weight,
// style: fontName.includes("Italic") ? "italic" : ("normal" as "italic" | "normal"),
// data: Buffer.from(fontsBase64Data[fontName], "base64"),
// })
// }
// }
export default fontsData

View File

@ -0,0 +1,29 @@
import { Resvg } from "@resvg/resvg-js"
import type { ReactElement } from "react"
import type { SatoriOptions } from "satori"
import satori from "satori"
import fonts from "./fonts"
export async function renderToImage(
node: ReactElement,
options: {
width?: number
height: number
debug?: boolean
fonts?: SatoriOptions["fonts"]
},
) {
const svg = await satori(node, {
...options,
fonts: options.fonts || fonts,
})
const w = new Resvg(svg)
const image = w.render().asPng()
return {
image,
contentType: "image/png",
}
}

View File

@ -0,0 +1,23 @@
export function buildSeoMetaTags(configs: {
openGraph: {
title: string
description?: string
image?: string | null
}
}) {
const { openGraph } = configs
return [
`<meta property="og:title" content="${openGraph.title}" />`,
openGraph.description
? `<meta property="og:description" content="${openGraph.description}" />`
: "",
openGraph.image ? `<meta property="og:image" content="${openGraph.image}" />` : "",
// Twitter
`<meta property="twitter:card" content="summary_large_image" />`,
`<meta property="twitter:title" content="${openGraph.title}" />`,
openGraph.description
? `<meta property="twitter:description" content="${openGraph.description}" />`
: "",
openGraph.image ? `<meta property="twitter:image" content="${openGraph.image}" />` : "",
].join("\n")
}

View File

@ -0,0 +1,138 @@
import { isBizId } from "@follow/utils/utils"
import type { FastifyRequest } from "fastify"
import { createApiClient, getTokenFromCookie } from "./lib/api-client"
interface MetaTagdata {
type: "meta"
property: string
content: string
}
interface MetaOpenGraph {
type: "openGraph"
title: string
description?: string
image?: string | null
}
interface MetaTitle {
type: "title"
title: string
}
interface MetaHydrateData {
type: "hydrate"
data: any
path: string
key: string
}
export type MetaTag = MetaTagdata | MetaOpenGraph | MetaTitle | MetaHydrateData
export async function injectMetaHandler(req: FastifyRequest): Promise<MetaTag[]> {
const metaArr = [] as MetaTag[]
const apiClient = createApiClient(getTokenFromCookie(req.headers.cookie || ""))
const hostFromReq = req.headers.host
const protocol = req.headers["x-forwarded-proto"] || req.protocol || "https"
const url = req.originalUrl
switch (true) {
case url.startsWith("/share/feeds"): {
const parsedUrl = new URL(url, `https://${hostFromReq}`)
const feedId = parsedUrl.pathname.split("/")[3]
const feed = await apiClient.feeds.$get({ query: { id: feedId } })
const { title, description } = feed.data.feed
metaArr.push(
{
type: "openGraph",
title: title || "",
description: description || "",
image: `${protocol}://${hostFromReq}/og/feed/${feedId}`,
},
{
type: "title",
title: title || "",
},
{
type: "hydrate",
data: feed.data,
path: apiClient.feeds.$url({ query: { id: feedId } }).pathname,
key: `feeds.$get,query:id=${feedId}`,
},
)
break
}
case url.startsWith("/share/lists"): {
const parsedUrl = new URL(url, `https://${hostFromReq}`)
const listId = parsedUrl.pathname.split("/")[3]
const list = await apiClient.lists.$get({ query: { listId } })
const { title, description } = list.data.list
metaArr.push(
{
type: "openGraph",
title: title || "",
description: description || "",
image: `${protocol}://${hostFromReq}/og/list/${listId}`,
},
{
type: "title",
title: title || "",
},
{
type: "hydrate",
data: list.data,
path: apiClient.lists.$url({ query: { listId } }).pathname,
key: `lists.$get,query:listId=${listId}`,
},
)
break
}
case url.startsWith("/share/users"): {
const parsedUrl = new URL(url, `https://${hostFromReq}`)
const userId = parsedUrl.pathname.split("/")[3]
const handle = isBizId(userId || "")
? userId
: `${userId}`.startsWith("@")
? `${userId}`.slice(1)
: userId
const res = await apiClient.profiles.$get({
query: {
handle,
id: isBizId(handle || "") ? handle : undefined,
},
})
const { name } = res.data
metaArr.push(
{
type: "title",
title: name || "",
},
{
type: "openGraph",
title: name || "",
image: `${protocol}://${hostFromReq}/og/user/${userId}`,
},
{
type: "hydrate",
data: res.data,
path: apiClient.profiles.$url({ query: { id: userId } }).pathname,
key: `profiles.$get,query:id=${userId}`,
},
)
break
}
}
return metaArr
}

View File

@ -0,0 +1,132 @@
/* eslint-disable no-param-reassign */
import { readFileSync } from "node:fs"
import path, { resolve } from "node:path"
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
import { parseHTML } from "linkedom"
import { FetchError } from "ofetch"
import { isDev } from "~/lib/env"
import { buildSeoMetaTags } from "~/lib/seo"
import { injectMetaHandler } from "../meta-handler"
const devHandler = (app: FastifyInstance) => {
app.get("*", async (req, reply) => {
const url = req.originalUrl
const root = resolve(__dirname, "../..")
const vite = require("../lib/dev-vite").getViteServer()
try {
let template = readFileSync(path.resolve(root, vite.config.root, "index.html"), "utf-8")
template = await vite.transformIndexHtml(url, template)
template = await safeInjectMetaToTemplate(template, req, reply)
reply.type("text/html")
reply.send(template)
} catch (e) {
vite.ssrFixStacktrace(e)
reply.code(500).send(e)
}
})
}
const prodHandler = (app: FastifyInstance) => {
app.get("*", async (req, reply) => {
let template = require("../../.generated/index.template").default
template = await safeInjectMetaToTemplate(template, req, reply)
const isInVercelReverseProxy = req.headers["x-middleware-subrequest"]
if (isInVercelReverseProxy) {
// Add asset prefix
// Map all link and script to /assets
// <script type="module" crossorigin src="/assets/index-kQ31_hj7.js"></script>
// <link rel="stylesheet" crossorigin href="/assets/index-BORNdGZP.css">
const prefix = "/external-dist"
const { document } = parseHTML(template)
for (const script of document.querySelectorAll("script")) {
script.src = `${prefix}${script.src}`
}
for (const link of document.querySelectorAll("link")) {
link.href = `${prefix}${link.href}`
}
template = document.toString()
}
reply.type("text/html")
reply.send(template)
})
}
export const globalRoute = isDev ? devHandler : prodHandler
async function safeInjectMetaToTemplate(template: string, req: FastifyRequest, res: FastifyReply) {
try {
return await injectMetaToTemplate(template, req)
} catch (e) {
console.error("inject meta error", e)
if (e instanceof FetchError && e.response?.status) {
res.code(e.response.status)
}
template = template.replace(`<!-- TITLE -->`, `Follow`)
return template
}
}
async function injectMetaToTemplate(template: string, req: FastifyRequest) {
const injectMetadata = await injectMetaHandler(req).catch((err) => {
if (isDev) {
throw err
}
return []
})
if (!injectMetadata) {
return template
}
const allMetaString = []
let isTitleReplaced = false
for (const meta of injectMetadata) {
switch (meta.type) {
case "openGraph": {
allMetaString.push(buildSeoMetaTags({ openGraph: meta }))
break
}
case "meta": {
allMetaString.push(`<meta property="${meta.property}" content="${meta.content}" />`)
break
}
case "title": {
if (meta.title) {
template = template.replace(`<!-- TITLE -->`, `${meta.title} | Follow`)
isTitleReplaced = true
}
break
}
case "hydrate": {
const { document } = parseHTML(template)
// Insert hydrate script
const script = document.createElement("script")
script.innerHTML = `
window.__HYDRATE__ = window.__HYDRATE__ || {}
window.__HYDRATE__['${meta.key}'] = ${JSON.stringify(meta.data)}
`
document.head.append(script)
template = document.toString()
break
}
}
}
if (!isTitleReplaced) {
template = template.replace(`<!-- TITLE -->`, `Follow`)
}
template = template.replace(`<!-- SSG-META -->`, allMetaString.join("\n"))
return template
}

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