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:
parent
2b98e6ef5e
commit
70acf916e5
|
|
@ -14,3 +14,8 @@ stats.html
|
|||
|
||||
electron.vite.config.*.mjs
|
||||
vite.config.*.mjs
|
||||
|
||||
# apps/server/**/fonts-data.ts
|
||||
# apps/server/**/index.template.ts
|
||||
|
||||
.generated
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ packages/shared/src/hono.ts
|
|||
pnpm-lock.yaml
|
||||
|
||||
CHANGELOG.md
|
||||
|
||||
apps/external/postcss.config.cjs
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ The project is currently under active development. The following screenshots are
|
|||

|
||||

|
||||
|
||||
- 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.
|
||||

|
||||
|
||||
- Extensive customization options
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,10 +10,4 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
declare module "react" {
|
||||
export interface AriaAttributes {
|
||||
"data-testid"?: string
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
declare const APP_VERSION: string
|
||||
declare const APP_NAME: string
|
||||
declare const RELEASE_CHANNEL: string
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ class UrlBuilderStatic {
|
|||
}
|
||||
|
||||
profile(id: string) {
|
||||
return this.join(`profile/${id}`)
|
||||
return this.join(`share/users/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ export function ListItem({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* TODO remove This only share page needed */}
|
||||
{feedId && !isFollowed && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -33,3 +33,10 @@ export {
|
|||
LazyLottieRenderContainer,
|
||||
LazyModalStackProvider,
|
||||
}
|
||||
|
||||
const LazyExternalJumpInProvider = lazy(() =>
|
||||
import("../external-jump-in-provider").then((res) => ({
|
||||
default: res.ExternalJumpInProvider,
|
||||
})),
|
||||
)
|
||||
export { LazyExternalJumpInProvider }
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*?asset" {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
:root,
|
||||
#shadow-html {
|
||||
--radius: 0.5rem;
|
||||
--a: theme("colors.theme.accent.DEFAULT");
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"noImplicitReturns": false,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "@follow/types/global", "@follow/types/react"],
|
||||
"paths": {
|
||||
"~/*": ["src/*"],
|
||||
"@pkg": ["../../package.json"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
../../.env
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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 },
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import type { CombinedEntryModel, FeedOrListRespModel } from "@follow/models/types"
|
||||
|
||||
export type UniversalItemProps = {
|
||||
entryId: string
|
||||
entryPreview?: CombinedEntryModel & {
|
||||
feeds: FeedOrListRespModel
|
||||
feedId: string
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
))
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export const siteConfig = {
|
||||
repoUrl: "https://github.com/RSSNext/Follow",
|
||||
appUrl: "https://app.follow.is",
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { domMax as default } from "framer-motion"
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { initI18n } from "@client/i18n"
|
||||
|
||||
import { initAnalytics } from "./analytics"
|
||||
import { initSentry } from "./sentry"
|
||||
|
||||
export const initialize = async () => {
|
||||
initI18n()
|
||||
initAnalytics()
|
||||
initSentry()
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { jotaiStore } from "@follow/utils/jotai"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { useSyncThemeWebApp } from "@follow/hooks"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
export const Component = () => {
|
||||
useSyncThemeWebApp()
|
||||
return <Outlet />
|
||||
}
|
||||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>>
|
||||
|
|
@ -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>>
|
||||
|
|
@ -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>
|
||||
>,
|
||||
})
|
||||
|
|
@ -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}`),
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"`,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1 @@
|
|||
declare const __DEV__: boolean
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const isDev = process.env.NODE_ENV === "development"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { resolve } from "node:path"
|
||||
|
||||
import { config } from "dotenv"
|
||||
|
||||
config({
|
||||
path: resolve(__dirname, "../../.env"),
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue