From 70acf916e54190758212aee1110135858fa1aa4f Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 21 Oct 2024 23:02:25 +0800 Subject: [PATCH] refactor: Separate main application and external pages and SEO support (#987) * init * chore: move tw config Signed-off-by: Innei * chore: update deps Signed-off-by: Innei * chore: add peer deps Signed-off-by: Innei * init * update Signed-off-by: Innei * test Signed-off-by: Innei * deps Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update hack Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * daisyui Signed-off-by: Innei * update Signed-off-by: Innei * update * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * update * update * update Signed-off-by: Innei * chore: auto-fix linting and formatting issues * feat: masonry Signed-off-by: Innei * padding Signed-off-by: Innei * fix og Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * fix: masonry re-render Signed-off-by: Innei * update grid Signed-off-by: Innei * update Signed-off-by: Innei * add op Signed-off-by: Innei * fix: error boundary lazy suspense Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * follow Signed-off-by: Innei * feat: profile Signed-off-by: Innei * profile Signed-off-by: Innei * refactor: og Signed-off-by: Innei * update Signed-off-by: Innei * update Signed-off-by: Innei * chore: auto-fix linting and formatting issues --------- Signed-off-by: Innei Co-authored-by: Innei --- .gitignore | 5 + .prettierignore | 2 + README.md | 2 +- apps/main/package.json | 2 +- apps/renderer/global.d.ts | 6 - apps/renderer/package.json | 1 + .../FeedFoundCanBeFollowErrorFallback.tsx | 106 +- apps/renderer/src/components/errors/helper.ts | 18 +- .../errors/previews/FeedPreview.tsx | 110 ++ .../src/components/ui/input/TextArea.tsx | 2 +- apps/renderer/src/env.d.ts | 1 - apps/renderer/src/global.d.ts | 18 - apps/renderer/src/hooks/common/index.ts | 1 + .../src/hooks/common/internal/for-theme.ts | 15 + apps/renderer/src/hooks/common/useDark.ts | 88 +- .../renderer/src/hooks/common/useSyncTheme.ts | 79 ++ apps/renderer/src/lib/url-builder.ts | 2 +- .../templates/list-item-template.tsx | 1 + .../src/modules/feed-column/auto-updater.tsx | 2 +- .../modules/profile/user-profile-modal.tsx | 7 +- .../(with-layout)/profile/[id]/index.tsx | 149 +-- .../(with-layout)/share/users/[id]/index.tsx | 142 +++ .../providers/external-jump-in-provider.tsx | 36 + .../src/providers/lazy/index.electron.ts | 1 + apps/renderer/src/providers/lazy/index.ts | 7 + .../renderer/src/providers/root-providers.tsx | 3 + apps/renderer/src/providers/setting-sync.tsx | 4 +- apps/renderer/src/shim.d.ts | 6 - apps/renderer/src/styles/tailwind.css | 1 - apps/renderer/tsconfig.json | 1 + apps/server/.env | 1 + apps/server/api/index.ts | 11 + apps/server/client/@types/constants.ts | 48 + apps/server/client/@types/default-resource.ts | 88 ++ apps/server/client/@types/i18next.d.ts | 10 + apps/server/client/App.tsx | 13 + apps/server/client/atoms/settings/general.ts | 47 + apps/server/client/atoms/settings/helper.ts | 97 ++ apps/server/client/atoms/user.ts | 8 + apps/server/client/components/items/grid.tsx | 85 ++ .../server/client/components/items/normal.tsx | 78 ++ .../items/picture-masonry-context.ts | 33 + .../client/components/items/picture.tsx | 310 +++++ apps/server/client/components/items/types.ts | 9 + .../client/components/layout/header/index.tsx | 109 ++ .../client/components/layout/main/index.tsx | 14 + .../components/layout/main/teleportal.tsx | 23 + .../components/ui/feed-certification.tsx | 73 ++ apps/server/client/components/ui/image.tsx | 69 ++ apps/server/client/configs.ts | 4 + apps/server/client/framer-lazy-feature.ts | 1 + apps/server/client/global.d.ts | 13 + apps/server/client/i18n.ts | 94 ++ apps/server/client/index.tsx | 22 + apps/server/client/initialize/analytics.ts | 32 + apps/server/client/initialize/helper.ts | 21 + apps/server/client/initialize/index.ts | 10 + apps/server/client/initialize/op.ts | 10 + apps/server/client/initialize/sentry.ts | 72 ++ apps/server/client/lib/api-fetch.ts | 37 + apps/server/client/lib/helper.ts | 49 + apps/server/client/lib/query-client.ts | 23 + apps/server/client/lib/store.ts | 1 + apps/server/client/pages/index.tsx | 9 + apps/server/client/pages/layout.tsx | 7 + .../client/pages/share/feeds/[id]/index.tsx | 160 +++ apps/server/client/pages/share/layout.tsx | 23 + .../client/pages/share/lists/[id]/index.tsx | 122 ++ .../client/pages/share/users/[id]/index.tsx | 128 ++ .../client/providers/root-providers.tsx | 39 + .../server/client/providers/user-provider.tsx | 17 + apps/server/client/query/auth.ts | 73 ++ apps/server/client/query/entries.ts | 20 + apps/server/client/query/feed.ts | 22 + apps/server/client/query/list.ts | 17 + apps/server/client/query/users.ts | 57 + apps/server/client/router.tsx | 39 + apps/server/client/styles/index.css | 32 + apps/server/index.html | 54 + apps/server/index.ts | 50 + apps/server/note.md | 14 + apps/server/package.json | 57 + apps/server/postcss.config.cjs | 8 + apps/server/public/favicon.ico | Bin 0 -> 15406 bytes apps/server/public/icon-192x192.png | Bin 0 -> 7589 bytes apps/server/public/icon-512x512.png | Bin 0 -> 30437 bytes apps/server/public/icon.svg | 1 + apps/server/public/manifest.json | 16 + apps/server/scripts/cleanup-vercel-build.ts | 10 + apps/server/scripts/prepare-vercel-build.ts | 55 + apps/server/src/global.d.ts | 1 + apps/server/src/lib/api-client.ts | 63 + apps/server/src/lib/dev-vite.ts | 27 + apps/server/src/lib/env.ts | 1 + apps/server/src/lib/load-env.ts | 7 + apps/server/src/lib/og/fonts.ts | 104 ++ apps/server/src/lib/og/render-to-image.ts | 29 + apps/server/src/lib/seo.ts | 23 + apps/server/src/meta-handler.ts | 138 +++ apps/server/src/router/global.ts | 132 ++ apps/server/src/router/og/__base.tsx | 183 +++ apps/server/src/router/og/feed.tsx | 103 ++ apps/server/src/router/og/index.ts | 68 + apps/server/src/router/og/list.tsx | 107 ++ apps/server/src/router/og/user.tsx | 83 ++ apps/server/src/vercel-rewrite.json | 52 + apps/server/tailwind.config.ts | 71 ++ apps/server/tsconfig.json | 40 + apps/server/tsup.config.ts | 43 + apps/server/vercel.json | 8 + apps/server/vite.config.mts | 35 + configs/tailwind.base.config.ts | 4 +- configs/vite.render.config.ts | 6 +- locales/app/en.json | 6 + locales/external/en.json | 6 + package.json | 6 +- packages/atoms/package.json | 20 + packages/atoms/src/atoms/user.ts | 8 + packages/atoms/src/helper/setting.ts | 94 ++ packages/atoms/tsconfig.json | 13 + packages/components/assets/colors.css | 70 ++ packages/components/assets/font.css | 16 + packages/components/assets/index.css | 3 + packages/components/assets/tailwind.css | 70 ++ packages/components/package.json | 61 + packages/components/src/atoms/route.ts | 40 + packages/components/src/atoms/viewport.ts | 31 + .../src/common/MemoedDangerousHTMLStyle.tsx | 19 + .../components/src/common/PoweredByFooter.tsx | 19 + packages/components/src/hooks/useViewport.ts | 26 + packages/components/src/icons/Language.tsx | 78 ++ .../src/icons/OouiUserAnonymous.tsx | 11 + .../components/src/icons/PhCloudCheck.tsx | 18 + .../components/src/icons/PhCloudWarning.tsx | 18 + packages/components/src/icons/PhCloudX.tsx | 18 + packages/components/src/icons/empty.tsx | 33 + packages/components/src/icons/follow.tsx | 12 + packages/components/src/icons/logo.tsx | 29 + packages/components/src/icons/resize.tsx | 12 + packages/components/src/icons/user.tsx | 22 + packages/components/src/icons/users.tsx | 19 + .../src/providers/event-provider.tsx | 37 + .../src/providers/stable-router-provider.tsx | 43 + packages/components/src/ui/avatar/index.tsx | 41 + packages/components/src/ui/button/index.tsx | 240 ++++ .../components/src/ui/button/variants.tsx | 74 ++ packages/components/src/ui/datetime/index.tsx | 91 ++ .../components/src/ui/divider/Divider.tsx | 48 + .../src/ui/divider/PanelSpliter.tsx | 59 + packages/components/src/ui/divider/index.ts | 2 + .../components/src/ui/feed-icon/index.tsx | 207 ++++ packages/components/src/ui/input/Input.tsx | 35 + packages/components/src/ui/input/TextArea.tsx | 108 ++ packages/components/src/ui/input/index.ts | 2 + packages/components/src/ui/kbd/Kbd.tsx | 298 +++++ packages/components/src/ui/loading/index.tsx | 139 +++ packages/components/src/ui/marquee/index.tsx | 60 + packages/components/src/ui/masonry/index.tsx | 217 ++++ .../src/ui/navigation-menu/index.tsx | 111 ++ .../src/ui/navigation-menu/style.ts | 5 + .../ui/platform-icon/collections/eagle.tsx | 12 + .../platform-icon/collections/instapaper.tsx | 12 + .../ui/platform-icon/collections/omnivore.tsx | 12 + .../ui/platform-icon/collections/readwise.tsx | 23 + .../src/ui/platform-icon/collections/rss3.tsx | 12 + .../components/src/ui/platform-icon/icons.ts | 5 + .../components/src/ui/platform-icon/index.tsx | 46 + .../components/src/ui/platform-icon/utils.tsx | 28 + .../src/ui/popover/index.module.css | 23 + packages/components/src/ui/popover/index.tsx | 35 + packages/components/src/ui/portal/index.tsx | 16 + .../components/src/ui/portal/provider.tsx | 11 + .../components/src/ui/radio-group/Radio.tsx | 48 + .../src/ui/radio-group/RadioCard.tsx | 54 + .../src/ui/radio-group/RadioGroup.tsx | 29 + .../components/src/ui/radio-group/context.ts | 15 + .../components/src/ui/radio-group/index.ts | 3 + .../src/ui/scroll-area/ScrollArea.tsx | 148 +++ packages/components/src/ui/scroll-area/ctx.ts | 3 + .../components/src/ui/scroll-area/hooks.ts | 103 ++ .../src/ui/scroll-area/index.module.css | 44 + .../components/src/ui/scroll-area/index.ts | 1 + packages/components/src/ui/segment/ctx.tsx | 8 + packages/components/src/ui/segment/index.tsx | 82 ++ packages/components/src/ui/select/index.tsx | 158 +++ packages/components/src/ui/toast/index.tsx | 17 + packages/components/src/ui/tooltip/index.tsx | 50 + packages/components/src/ui/tooltip/styles.ts | 10 + .../src/ui/typography/EllipsisWithTooltip.tsx | 86 ++ .../components/src/ui/typography/index.ts | 1 + packages/components/src/utils/dayjs.ts | 8 + packages/components/src/utils/icon.ts | 33 + packages/components/tsconfig.json | 14 + packages/constants/package.json | 10 + packages/constants/src/enums.ts | 13 + packages/constants/src/index.ts | 2 + packages/constants/src/internal/i18n.ts | 36 + packages/constants/src/tabs.tsx | 69 ++ packages/constants/tsconfig.json | 13 + packages/hooks/package.json | 24 + .../hooks/src/factory/createHTMLMediaHook.ts | 287 +++++ packages/hooks/src/index.ts | 14 + packages/hooks/src/internal/for-theme.ts | 14 + packages/hooks/src/useAnyPointDown.ts | 7 + packages/hooks/src/useDark.ts | 15 + packages/hooks/src/useInputComposition.ts | 96 ++ packages/hooks/src/useInterval.ts | 56 + packages/hooks/src/useIsOnline.ts | 20 + packages/hooks/src/useMeasure.ts | 210 ++++ packages/hooks/src/usePageVisibility.ts | 17 + packages/hooks/src/usePrevious.ts | 9 + packages/hooks/src/useRefValue.ts | 15 + packages/hooks/src/useSetState.ts | 17 + packages/hooks/src/useSyncTheme.ts | 51 + packages/hooks/src/useTitle.ts | 16 + .../hooks/src/useTypescriptHappyCallback.ts | 6 + packages/hooks/src/useVideo.ts | 3 + packages/hooks/tsconfig.json | 15 + packages/models/package.json | 22 + packages/models/src/index.ts | 41 + packages/models/src/types.ts | 122 ++ packages/models/tsconfig.json | 14 + packages/shared/package.json | 17 +- packages/shared/src/env.ts | 13 + packages/tsconfig.extend.json | 20 + packages/types/global.d.ts | 10 + packages/types/package.json | 8 + packages/types/react-global.d.ts | 23 + packages/utils/package.json | 32 + packages/utils/src/chain.ts | 27 + packages/utils/src/color.ts | 54 + packages/utils/src/dom.ts | 23 + packages/utils/src/environment.ts | 22 + packages/utils/src/event-bus.ts | 35 + packages/utils/src/img-proxy.ts | 34 + packages/utils/src/jotai.ts | 37 + packages/utils/src/link-parser.ts | 114 ++ packages/utils/src/ns.ts | 11 + packages/utils/src/route-builder.test.ts | 176 +++ packages/utils/src/route-builder.ts | 188 +++ packages/utils/src/url-builder.ts | 26 + packages/utils/src/utils.spec.ts | 29 + packages/utils/src/utils.ts | 199 +++ packages/utils/tsconfig.json | 10 + patches/daisyui.patch | 44 + patches/hono.patch | 4 +- pnpm-lock.yaml | 1100 ++++++++++++++++- 247 files changed, 11556 insertions(+), 370 deletions(-) create mode 100644 apps/renderer/src/components/errors/previews/FeedPreview.tsx create mode 100644 apps/renderer/src/hooks/common/internal/for-theme.ts create mode 100644 apps/renderer/src/hooks/common/useSyncTheme.ts create mode 100644 apps/renderer/src/pages/(external)/(with-layout)/share/users/[id]/index.tsx create mode 100644 apps/renderer/src/providers/external-jump-in-provider.tsx delete mode 100644 apps/renderer/src/shim.d.ts create mode 120000 apps/server/.env create mode 100644 apps/server/api/index.ts create mode 100644 apps/server/client/@types/constants.ts create mode 100644 apps/server/client/@types/default-resource.ts create mode 100644 apps/server/client/@types/i18next.d.ts create mode 100644 apps/server/client/App.tsx create mode 100644 apps/server/client/atoms/settings/general.ts create mode 100644 apps/server/client/atoms/settings/helper.ts create mode 100644 apps/server/client/atoms/user.ts create mode 100644 apps/server/client/components/items/grid.tsx create mode 100644 apps/server/client/components/items/normal.tsx create mode 100644 apps/server/client/components/items/picture-masonry-context.ts create mode 100644 apps/server/client/components/items/picture.tsx create mode 100644 apps/server/client/components/items/types.ts create mode 100644 apps/server/client/components/layout/header/index.tsx create mode 100644 apps/server/client/components/layout/main/index.tsx create mode 100644 apps/server/client/components/layout/main/teleportal.tsx create mode 100644 apps/server/client/components/ui/feed-certification.tsx create mode 100644 apps/server/client/components/ui/image.tsx create mode 100644 apps/server/client/configs.ts create mode 100644 apps/server/client/framer-lazy-feature.ts create mode 100644 apps/server/client/global.d.ts create mode 100644 apps/server/client/i18n.ts create mode 100644 apps/server/client/index.tsx create mode 100644 apps/server/client/initialize/analytics.ts create mode 100644 apps/server/client/initialize/helper.ts create mode 100644 apps/server/client/initialize/index.ts create mode 100644 apps/server/client/initialize/op.ts create mode 100644 apps/server/client/initialize/sentry.ts create mode 100644 apps/server/client/lib/api-fetch.ts create mode 100644 apps/server/client/lib/helper.ts create mode 100644 apps/server/client/lib/query-client.ts create mode 100644 apps/server/client/lib/store.ts create mode 100644 apps/server/client/pages/index.tsx create mode 100644 apps/server/client/pages/layout.tsx create mode 100644 apps/server/client/pages/share/feeds/[id]/index.tsx create mode 100644 apps/server/client/pages/share/layout.tsx create mode 100644 apps/server/client/pages/share/lists/[id]/index.tsx create mode 100644 apps/server/client/pages/share/users/[id]/index.tsx create mode 100644 apps/server/client/providers/root-providers.tsx create mode 100644 apps/server/client/providers/user-provider.tsx create mode 100644 apps/server/client/query/auth.ts create mode 100644 apps/server/client/query/entries.ts create mode 100644 apps/server/client/query/feed.ts create mode 100644 apps/server/client/query/list.ts create mode 100644 apps/server/client/query/users.ts create mode 100644 apps/server/client/router.tsx create mode 100644 apps/server/client/styles/index.css create mode 100644 apps/server/index.html create mode 100644 apps/server/index.ts create mode 100644 apps/server/note.md create mode 100644 apps/server/package.json create mode 100644 apps/server/postcss.config.cjs create mode 100644 apps/server/public/favicon.ico create mode 100644 apps/server/public/icon-192x192.png create mode 100644 apps/server/public/icon-512x512.png create mode 100644 apps/server/public/icon.svg create mode 100644 apps/server/public/manifest.json create mode 100644 apps/server/scripts/cleanup-vercel-build.ts create mode 100644 apps/server/scripts/prepare-vercel-build.ts create mode 100644 apps/server/src/global.d.ts create mode 100644 apps/server/src/lib/api-client.ts create mode 100644 apps/server/src/lib/dev-vite.ts create mode 100644 apps/server/src/lib/env.ts create mode 100644 apps/server/src/lib/load-env.ts create mode 100644 apps/server/src/lib/og/fonts.ts create mode 100644 apps/server/src/lib/og/render-to-image.ts create mode 100644 apps/server/src/lib/seo.ts create mode 100644 apps/server/src/meta-handler.ts create mode 100644 apps/server/src/router/global.ts create mode 100644 apps/server/src/router/og/__base.tsx create mode 100644 apps/server/src/router/og/feed.tsx create mode 100644 apps/server/src/router/og/index.ts create mode 100644 apps/server/src/router/og/list.tsx create mode 100644 apps/server/src/router/og/user.tsx create mode 100644 apps/server/src/vercel-rewrite.json create mode 100644 apps/server/tailwind.config.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/server/tsup.config.ts create mode 100644 apps/server/vercel.json create mode 100644 apps/server/vite.config.mts create mode 100644 packages/atoms/package.json create mode 100644 packages/atoms/src/atoms/user.ts create mode 100644 packages/atoms/src/helper/setting.ts create mode 100644 packages/atoms/tsconfig.json create mode 100644 packages/components/assets/colors.css create mode 100644 packages/components/assets/font.css create mode 100644 packages/components/assets/index.css create mode 100644 packages/components/assets/tailwind.css create mode 100644 packages/components/package.json create mode 100644 packages/components/src/atoms/route.ts create mode 100644 packages/components/src/atoms/viewport.ts create mode 100644 packages/components/src/common/MemoedDangerousHTMLStyle.tsx create mode 100644 packages/components/src/common/PoweredByFooter.tsx create mode 100644 packages/components/src/hooks/useViewport.ts create mode 100644 packages/components/src/icons/Language.tsx create mode 100644 packages/components/src/icons/OouiUserAnonymous.tsx create mode 100644 packages/components/src/icons/PhCloudCheck.tsx create mode 100644 packages/components/src/icons/PhCloudWarning.tsx create mode 100644 packages/components/src/icons/PhCloudX.tsx create mode 100644 packages/components/src/icons/empty.tsx create mode 100644 packages/components/src/icons/follow.tsx create mode 100644 packages/components/src/icons/logo.tsx create mode 100644 packages/components/src/icons/resize.tsx create mode 100644 packages/components/src/icons/user.tsx create mode 100644 packages/components/src/icons/users.tsx create mode 100644 packages/components/src/providers/event-provider.tsx create mode 100644 packages/components/src/providers/stable-router-provider.tsx create mode 100644 packages/components/src/ui/avatar/index.tsx create mode 100644 packages/components/src/ui/button/index.tsx create mode 100644 packages/components/src/ui/button/variants.tsx create mode 100644 packages/components/src/ui/datetime/index.tsx create mode 100644 packages/components/src/ui/divider/Divider.tsx create mode 100644 packages/components/src/ui/divider/PanelSpliter.tsx create mode 100644 packages/components/src/ui/divider/index.ts create mode 100644 packages/components/src/ui/feed-icon/index.tsx create mode 100644 packages/components/src/ui/input/Input.tsx create mode 100644 packages/components/src/ui/input/TextArea.tsx create mode 100644 packages/components/src/ui/input/index.ts create mode 100644 packages/components/src/ui/kbd/Kbd.tsx create mode 100644 packages/components/src/ui/loading/index.tsx create mode 100644 packages/components/src/ui/marquee/index.tsx create mode 100644 packages/components/src/ui/masonry/index.tsx create mode 100644 packages/components/src/ui/navigation-menu/index.tsx create mode 100644 packages/components/src/ui/navigation-menu/style.ts create mode 100644 packages/components/src/ui/platform-icon/collections/eagle.tsx create mode 100644 packages/components/src/ui/platform-icon/collections/instapaper.tsx create mode 100644 packages/components/src/ui/platform-icon/collections/omnivore.tsx create mode 100644 packages/components/src/ui/platform-icon/collections/readwise.tsx create mode 100644 packages/components/src/ui/platform-icon/collections/rss3.tsx create mode 100644 packages/components/src/ui/platform-icon/icons.ts create mode 100644 packages/components/src/ui/platform-icon/index.tsx create mode 100644 packages/components/src/ui/platform-icon/utils.tsx create mode 100644 packages/components/src/ui/popover/index.module.css create mode 100644 packages/components/src/ui/popover/index.tsx create mode 100644 packages/components/src/ui/portal/index.tsx create mode 100644 packages/components/src/ui/portal/provider.tsx create mode 100644 packages/components/src/ui/radio-group/Radio.tsx create mode 100644 packages/components/src/ui/radio-group/RadioCard.tsx create mode 100644 packages/components/src/ui/radio-group/RadioGroup.tsx create mode 100644 packages/components/src/ui/radio-group/context.ts create mode 100644 packages/components/src/ui/radio-group/index.ts create mode 100644 packages/components/src/ui/scroll-area/ScrollArea.tsx create mode 100644 packages/components/src/ui/scroll-area/ctx.ts create mode 100644 packages/components/src/ui/scroll-area/hooks.ts create mode 100644 packages/components/src/ui/scroll-area/index.module.css create mode 100644 packages/components/src/ui/scroll-area/index.ts create mode 100644 packages/components/src/ui/segment/ctx.tsx create mode 100644 packages/components/src/ui/segment/index.tsx create mode 100644 packages/components/src/ui/select/index.tsx create mode 100644 packages/components/src/ui/toast/index.tsx create mode 100644 packages/components/src/ui/tooltip/index.tsx create mode 100644 packages/components/src/ui/tooltip/styles.ts create mode 100644 packages/components/src/ui/typography/EllipsisWithTooltip.tsx create mode 100644 packages/components/src/ui/typography/index.ts create mode 100644 packages/components/src/utils/dayjs.ts create mode 100644 packages/components/src/utils/icon.ts create mode 100644 packages/components/tsconfig.json create mode 100644 packages/constants/package.json create mode 100644 packages/constants/src/enums.ts create mode 100644 packages/constants/src/index.ts create mode 100644 packages/constants/src/internal/i18n.ts create mode 100644 packages/constants/src/tabs.tsx create mode 100644 packages/constants/tsconfig.json create mode 100644 packages/hooks/package.json create mode 100644 packages/hooks/src/factory/createHTMLMediaHook.ts create mode 100644 packages/hooks/src/index.ts create mode 100644 packages/hooks/src/internal/for-theme.ts create mode 100644 packages/hooks/src/useAnyPointDown.ts create mode 100644 packages/hooks/src/useDark.ts create mode 100644 packages/hooks/src/useInputComposition.ts create mode 100644 packages/hooks/src/useInterval.ts create mode 100644 packages/hooks/src/useIsOnline.ts create mode 100644 packages/hooks/src/useMeasure.ts create mode 100644 packages/hooks/src/usePageVisibility.ts create mode 100644 packages/hooks/src/usePrevious.ts create mode 100644 packages/hooks/src/useRefValue.ts create mode 100644 packages/hooks/src/useSetState.ts create mode 100644 packages/hooks/src/useSyncTheme.ts create mode 100644 packages/hooks/src/useTitle.ts create mode 100644 packages/hooks/src/useTypescriptHappyCallback.ts create mode 100644 packages/hooks/src/useVideo.ts create mode 100644 packages/hooks/tsconfig.json create mode 100644 packages/models/package.json create mode 100644 packages/models/src/index.ts create mode 100644 packages/models/src/types.ts create mode 100644 packages/models/tsconfig.json create mode 100644 packages/tsconfig.extend.json create mode 100644 packages/types/global.d.ts create mode 100644 packages/types/package.json create mode 100644 packages/types/react-global.d.ts create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/chain.ts create mode 100644 packages/utils/src/color.ts create mode 100644 packages/utils/src/dom.ts create mode 100644 packages/utils/src/environment.ts create mode 100644 packages/utils/src/event-bus.ts create mode 100644 packages/utils/src/img-proxy.ts create mode 100644 packages/utils/src/jotai.ts create mode 100644 packages/utils/src/link-parser.ts create mode 100644 packages/utils/src/ns.ts create mode 100644 packages/utils/src/route-builder.test.ts create mode 100644 packages/utils/src/route-builder.ts create mode 100644 packages/utils/src/url-builder.ts create mode 100644 packages/utils/src/utils.spec.ts create mode 100644 packages/utils/src/utils.ts create mode 100644 packages/utils/tsconfig.json create mode 100644 patches/daisyui.patch diff --git a/.gitignore b/.gitignore index 3f9276ef6..328735892 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ stats.html electron.vite.config.*.mjs vite.config.*.mjs + +# apps/server/**/fonts-data.ts +# apps/server/**/index.template.ts + +.generated diff --git a/.prettierignore b/.prettierignore index 479f6242c..e56ce5def 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,5 @@ packages/shared/src/hono.ts pnpm-lock.yaml CHANGELOG.md + +apps/external/postcss.config.cjs diff --git a/README.md b/README.md index ce5397661..262f14252 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ The project is currently under active development. The following screenshots are ![Screenshot 2024-09-10 at 6 27 08 PM](https://github.com/user-attachments/assets/82d71c46-c039-41b9-b3cd-5ad078ff14a5) ![Screenshot 2024-09-10 at 8 10 56 PM](https://github.com/user-attachments/assets/2424dfd4-59a6-4dad-8eb7-8db61a711b45) -- Social features. Follow is also a social platform that allows you to follow other users, share your subscriptions, and discover new content. It also offers a subscription list synchronization feature, enabling your friends to sync with your subscriptions. Click [here](https://app.follow.is/profile/54728159538884608) to view my automatically generated personal page. +- Social features. Follow is also a social platform that allows you to follow other users, share your subscriptions, and discover new content. It also offers a subscription list synchronization feature, enabling your friends to sync with your subscriptions. Click [here](https://app.follow.is/share/users/54728159538884608) to view my automatically generated personal page. ![Screenshot 2024-09-10 at 6 26 37 PM](https://github.com/user-attachments/assets/b636fd6d-0fcb-436b-8856-f7a965dba12d) - Extensive customization options diff --git a/apps/main/package.json b/apps/main/package.json index a0f2c4070..22f308a94 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -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" } } diff --git a/apps/renderer/global.d.ts b/apps/renderer/global.d.ts index fbd50a0b9..c60d1f76d 100644 --- a/apps/renderer/global.d.ts +++ b/apps/renderer/global.d.ts @@ -10,10 +10,4 @@ declare global { } } -declare module "react" { - export interface AriaAttributes { - "data-testid"?: string - } -} - export {} diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 3bf725c66..001f138ee 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -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", diff --git a/apps/renderer/src/components/errors/FeedFoundCanBeFollowErrorFallback.tsx b/apps/renderer/src/components/errors/FeedFoundCanBeFollowErrorFallback.tsx index 351dd1115..68532a7bd 100644 --- a/apps/renderer/src/components/errors/FeedFoundCanBeFollowErrorFallback.tsx +++ b/apps/renderer/src/components/errors/FeedFoundCanBeFollowErrorFallback.tsx @@ -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 = ({ resetError, error }) => { if (!(error instanceof FeedFoundCanBeFollowError)) { @@ -24,65 +24,59 @@ const FeedFoundCanBeFollowErrorFallback: FC = ({ resetErr useResetErrorWhenRouteChange(resetError) return ( -
-
- +
+ + {{ + actions: ( +
+ -
{feed.title}
+ + const entriesOptions = { + id: feedId, + view, + } - -
-
+ entries.entries(entriesOptions).remove() + nextFrame(() => resetError()) + }} + /> + ), + }) + }} + variant="primary" + > + Follow + +
+ ), + }} +
) } diff --git a/apps/renderer/src/components/errors/helper.ts b/apps/renderer/src/components/errors/helper.ts index 27a3f7b3e..17b6ec3e2 100644 --- a/apps/renderer/src/components/errors/helper.ts +++ b/apps/renderer/src/components/errors/helper.ts @@ -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>( + error: S, + Component: FC, +): FC => { + return (props: AppErrorFallbackProps) => { + if (!(props.error instanceof error)) { + throw error + } + + return createElement(Component, props) + } +} diff --git a/apps/renderer/src/components/errors/previews/FeedPreview.tsx b/apps/renderer/src/components/errors/previews/FeedPreview.tsx new file mode 100644 index 000000000..d6a6b0981 --- /dev/null +++ b/apps/renderer/src/components/errors/previews/FeedPreview.tsx @@ -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 = getItemComponentByView(view as FeedViewType) + + const { t } = useTranslation() + return ( + <> + {feed.isLoading ? ( + + ) : ( + feed.data?.feed && ( +
+ +
+
+

{feed.data.feed.title}

+ +
+
{feed.data.feed.description}
+
+
+ {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, + })} +
+ + {children.actions} +
+ {entries.data?.map((entry) => ( + +
+ +
+
+ ))} +
+
+ ) + )} + + ) +} diff --git a/apps/renderer/src/components/ui/input/TextArea.tsx b/apps/renderer/src/components/ui/input/TextArea.tsx index ad7fe907c..ee3b3f3bf 100644 --- a/apps/renderer/src/components/ui/input/TextArea.tsx +++ b/apps/renderer/src/components/ui/input/TextArea.tsx @@ -53,7 +53,7 @@ export const TextArea = forwardRef< return (
declare const APP_VERSION: string declare const APP_NAME: string declare const RELEASE_CHANNEL: string diff --git a/apps/renderer/src/global.d.ts b/apps/renderer/src/global.d.ts index b60a478bc..e09e5150d 100644 --- a/apps/renderer/src/global.d.ts +++ b/apps/renderer/src/global.d.ts @@ -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

= FC - - export type ComponentType

= { - className?: string - } & PropsWithChildren & - P - export type Nullable = 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[0]> export type I18nKeysForSettings = OmitStringType[0]> export type I18nKeysForShortcuts = OmitStringType[0]> - - type IsLiteralString = T extends string ? (string extends T ? never : T) : never - - type OmitStringType = T extends any[] ? OmitStringType : IsLiteralString } export {} diff --git a/apps/renderer/src/hooks/common/index.ts b/apps/renderer/src/hooks/common/index.ts index b6578ae1e..43084b9d3 100644 --- a/apps/renderer/src/hooks/common/index.ts +++ b/apps/renderer/src/hooks/common/index.ts @@ -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" diff --git a/apps/renderer/src/hooks/common/internal/for-theme.ts b/apps/renderer/src/hooks/common/internal/for-theme.ts new file mode 100644 index 000000000..2ff00f285 --- /dev/null +++ b/apps/renderer/src/hooks/common/internal/for-theme.ts @@ -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) diff --git a/apps/renderer/src/hooks/common/useDark.ts b/apps/renderer/src/hooks/common/useDark.ts index ce69f2881..9ea5cd0d6 100644 --- a/apps/renderer/src/hooks/common/useDark.ts +++ b/apps/renderer/src/hooks/common/useDark.ts @@ -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" ? (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() - }) - } -} diff --git a/apps/renderer/src/hooks/common/useSyncTheme.ts b/apps/renderer/src/hooks/common/useSyncTheme.ts new file mode 100644 index 000000000..393e31470 --- /dev/null +++ b/apps/renderer/src/hooks/common/useSyncTheme.ts @@ -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" ? (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() + }) + } +} diff --git a/apps/renderer/src/lib/url-builder.ts b/apps/renderer/src/lib/url-builder.ts index be35aad1f..28635a1e2 100644 --- a/apps/renderer/src/lib/url-builder.ts +++ b/apps/renderer/src/lib/url-builder.ts @@ -19,7 +19,7 @@ class UrlBuilderStatic { } profile(id: string) { - return this.join(`profile/${id}`) + return this.join(`share/users/${id}`) } } diff --git a/apps/renderer/src/modules/entry-column/templates/list-item-template.tsx b/apps/renderer/src/modules/entry-column/templates/list-item-template.tsx index ac85dc898..5c518abc6 100644 --- a/apps/renderer/src/modules/entry-column/templates/list-item-template.tsx +++ b/apps/renderer/src/modules/entry-column/templates/list-item-template.tsx @@ -149,6 +149,7 @@ export function ListItem({ )}

+ {/* TODO remove This only share page needed */} {feedId && !isFollowed && ( - - - ), - )} - - - )) - )} - - - - )} - - ) +export const loader = async ({ params }: { params: { id: string } }) => { + return redirect(`/share/users/${params.id}`) +} +/** + * @deprecated remove after split application + */ +export function Component() { + return null } diff --git a/apps/renderer/src/pages/(external)/(with-layout)/share/users/[id]/index.tsx b/apps/renderer/src/pages/(external)/(with-layout)/share/users/[id]/index.tsx new file mode 100644 index 000000000..24176ef8b --- /dev/null +++ b/apps/renderer/src/pages/(external)/(with-layout)/share/users/[id]/index.tsx @@ -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 ( +
+ {user.isLoading ? ( + + ) : ( + + + + {user.data?.name?.slice(0, 2)} + +
+
+

{user.data?.name}

+
+
{user.data?.handle}
+
+
+ {subscriptions.isLoading ? ( + + ) : ( + Object.keys(subscriptions.data || {}).map((category) => ( +
+
+

{category}

+
+
+ {subscriptions.data?.[category].map( + (subscription) => + "feeds" in subscription && ( +
+ + +
+
+ {subscription.feeds?.title} +
+ {subscription.feeds?.description && ( +
+ {subscription.feeds.description} +
+ )} +
+
+ + + +
+ ), + )} +
+
+ )) + )} +
+ +
+ )} +
+ ) +} diff --git a/apps/renderer/src/providers/external-jump-in-provider.tsx b/apps/renderer/src/providers/external-jump-in-provider.tsx new file mode 100644 index 000000000..fba0ffc91 --- /dev/null +++ b/apps/renderer/src/providers/external-jump-in-provider.tsx @@ -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 +} diff --git a/apps/renderer/src/providers/lazy/index.electron.ts b/apps/renderer/src/providers/lazy/index.electron.ts index 50a081c33..d10b826ae 100644 --- a/apps/renderer/src/providers/lazy/index.electron.ts +++ b/apps/renderer/src/providers/lazy/index.electron.ts @@ -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" diff --git a/apps/renderer/src/providers/lazy/index.ts b/apps/renderer/src/providers/lazy/index.ts index 48d13bc5d..11ba453e6 100644 --- a/apps/renderer/src/providers/lazy/index.ts +++ b/apps/renderer/src/providers/lazy/index.ts @@ -33,3 +33,10 @@ export { LazyLottieRenderContainer, LazyModalStackProvider, } + +const LazyExternalJumpInProvider = lazy(() => + import("../external-jump-in-provider").then((res) => ({ + default: res.ExternalJumpInProvider, + })), +) +export { LazyExternalJumpInProvider } diff --git a/apps/renderer/src/providers/root-providers.tsx b/apps/renderer/src/providers/root-providers.tsx index c98d2cf16..f5d9204e6 100644 --- a/apps/renderer/src/providers/root-providers.tsx +++ b/apps/renderer/src/providers/root-providers.tsx @@ -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 = ({ children }) => ( + + diff --git a/apps/renderer/src/providers/setting-sync.tsx b/apps/renderer/src/providers/setting-sync.tsx index 6595864fd..ac73a5aeb 100644 --- a/apps/renderer/src/providers/setting-sync.tsx +++ b/apps/renderer/src/providers/setting-sync.tsx @@ -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` diff --git a/apps/renderer/src/shim.d.ts b/apps/renderer/src/shim.d.ts deleted file mode 100644 index 5391ffe55..000000000 --- a/apps/renderer/src/shim.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// - -declare module "*?asset" { - const src: string - export default src -} diff --git a/apps/renderer/src/styles/tailwind.css b/apps/renderer/src/styles/tailwind.css index 45f214761..5131010d2 100644 --- a/apps/renderer/src/styles/tailwind.css +++ b/apps/renderer/src/styles/tailwind.css @@ -8,7 +8,6 @@ :root, #shadow-html { --radius: 0.5rem; - --a: theme("colors.theme.accent.DEFAULT"); } [data-theme="light"] { diff --git a/apps/renderer/tsconfig.json b/apps/renderer/tsconfig.json index 1f6fa670c..555a03aef 100644 --- a/apps/renderer/tsconfig.json +++ b/apps/renderer/tsconfig.json @@ -11,6 +11,7 @@ "noImplicitReturns": false, "noEmit": true, "skipLibCheck": true, + "types": ["vite/client", "@follow/types/global", "@follow/types/react"], "paths": { "~/*": ["src/*"], "@pkg": ["../../package.json"], diff --git a/apps/server/.env b/apps/server/.env new file mode 120000 index 000000000..c7360fb82 --- /dev/null +++ b/apps/server/.env @@ -0,0 +1 @@ +../../.env \ No newline at end of file diff --git a/apps/server/api/index.ts b/apps/server/api/index.ts new file mode 100644 index 000000000..ff2d9f11d --- /dev/null +++ b/apps/server/api/index.ts @@ -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) +} diff --git a/apps/server/client/@types/constants.ts b/apps/server/client/@types/constants.ts new file mode 100644 index 000000000..5b51c1b9a --- /dev/null +++ b/apps/server/client/@types/constants.ts @@ -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 diff --git a/apps/server/client/@types/default-resource.ts b/apps/server/client/@types/default-resource.ts new file mode 100644 index 000000000..b9e9a0c77 --- /dev/null +++ b/apps/server/client/@types/default-resource.ts @@ -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 }, +} diff --git a/apps/server/client/@types/i18next.d.ts b/apps/server/client/@types/i18next.d.ts new file mode 100644 index 000000000..ec6bd1544 --- /dev/null +++ b/apps/server/client/@types/i18next.d.ts @@ -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 + } +} diff --git a/apps/server/client/App.tsx b/apps/server/client/App.tsx new file mode 100644 index 000000000..6f079f8e1 --- /dev/null +++ b/apps/server/client/App.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router-dom" + +import { RootProviders } from "./providers/root-providers" + +function App() { + return ( + + + + ) +} + +export { App as Component } diff --git a/apps/server/client/atoms/settings/general.ts b/apps/server/client/atoms/settings/general.ts new file mode 100644 index 000000000..a73925500 --- /dev/null +++ b/apps/server/client/atoms/settings/general.ts @@ -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", +] diff --git a/apps/server/client/atoms/settings/helper.ts b/apps/server/client/atoms/settings/helper.ts new file mode 100644 index 000000000..fc35c033c --- /dev/null +++ b/apps/server/client/atoms/settings/helper.ts @@ -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 = ( + 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, any> + + const useSettingKey = >(key: T) => { + let selectedAtom = selectAtomCacheMap[key] + if (!selectedAtom) { + selectedAtom = selectAtom(atom, (s) => s[key]) + selectAtomCacheMap[key] = selectedAtom + } + + return useAtomValue(selectedAtom) as ReturnType[T] + } + + const useSettingSelector = < + T extends keyof ReturnType, + S extends ReturnType, + 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 = >( + key: K, + value: ReturnType[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>>(key: T) => Awaited + } + getSettings: typeof getSettings + settingAtom: typeof atom + } +} diff --git a/apps/server/client/atoms/user.ts b/apps/server/client/atoms/user.ts new file mode 100644 index 000000000..4891a10f2 --- /dev/null +++ b/apps/server/client/atoms/user.ts @@ -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>(null)) + +export const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] = + createAtomHooks(atom(false)) diff --git a/apps/server/client/components/items/grid.tsx b/apps/server/client/components/items/grid.tsx new file mode 100644 index 000000000..468f497eb --- /dev/null +++ b/apps/server/client/components/items/grid.tsx @@ -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 ( + +
+ {entries.map((entry) => ( + +
+ +
+ +
+ ))} +
+
+ ) +} + +const Wrapper: FC<{ + children: React.ReactNode + href: string + wrapperClassName?: string +}> = ({ children, href, wrapperClassName }) => { + return ( + + {children} + + ) +} + +const GridItemFooter: FC<{ + feed: Feed + entryId: string + entryPreview: EntriesPreview[number] +}> = ({ feed, entryPreview }) => { + return ( +
+
+
+ {entryPreview.title} +
+
+
+ + {feed.feed.title} + · + + {dayjs + .duration(dayjs(entryPreview.publishedAt).diff(dayjs(), "minute"), "minute") + .humanize()} + +
+
+ ) +} diff --git a/apps/server/client/components/items/normal.tsx b/apps/server/client/components/items/normal.tsx new file mode 100644 index 000000000..661f38ce0 --- /dev/null +++ b/apps/server/client/components/items/normal.tsx @@ -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 ( +
+ +
+
+ + {feed?.title} + + · + {!!displayTime && } +
+
+ {entry.entries.title} + + {/* {!!entry.collections && } */} +
+ {withDetails && ( +
+ {entry.entries.description} +
+ )} +
+ + {withDetails && entry.entries.media?.[0] && ( +
+ +
+ )} +
+ ) +} + +export const NormalListItem = memo(NormalListItemImpl) diff --git a/apps/server/client/components/items/picture-masonry-context.ts b/apps/server/client/components/items/picture-masonry-context.ts new file mode 100644 index 000000000..ce611c3aa --- /dev/null +++ b/apps/server/client/components/items/picture-masonry-context.ts @@ -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) + +export const MasonryIntersectionContext = createContext(null!) + +export const useMasonryItemRatio = (url: string) => + useContextSelector(MasonryItemsAspectRatioContext, (ctx) => ctx[url]) + +export const MasonryItemsAspectRatioSetterContext = + createContext>>>(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], + ) +} diff --git a/apps/server/client/components/items/picture.tsx b/apps/server/client/components/items/picture.tsx new file mode 100644 index 000000000..31eb8e68f --- /dev/null +++ b/apps/server/client/components/items/picture.tsx @@ -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 ( +
+ {children} +
+ ) +} + +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>({}) + const [currentItemWidth, setCurrentItemWidth] = useState(0) + const [currentColumn, setCurrentColumn] = useState(1) + const containerRef = useRef(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() + 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 ( + + +
+ {inlineStyle} +
+ {isInitLayout && ( + + + +
+ +
+
+
+
+ )} +
+
+
+
+ ) +} + +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 ( + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + + + + + {isHovered && ( + +
+
+
+ + + )} + +
+ + ) +}) +const GridItemFooter = ({ + entry, + + titleClassName, + descriptionClassName, + timeClassName, + feed, +}: { + entry: EntryModel + feed: Feed + titleClassName?: string + descriptionClassName?: string + timeClassName?: string +}) => { + return ( +
+
+
+
+ {entry.title} +
+
+
+ + + {getPreferredTitle(feed.feed)} + + · + + {dayjs.duration(dayjs(entry.publishedAt).diff(dayjs(), "minute"), "minute").humanize()} + +
+
+ ) +} diff --git a/apps/server/client/components/items/types.ts b/apps/server/client/components/items/types.ts new file mode 100644 index 000000000..f4f5fcaca --- /dev/null +++ b/apps/server/client/components/items/types.ts @@ -0,0 +1,9 @@ +import type { CombinedEntryModel, FeedOrListRespModel } from "@follow/models/types" + +export type UniversalItemProps = { + entryId: string + entryPreview?: CombinedEntryModel & { + feeds: FeedOrListRespModel + feedId: string + } +} diff --git a/apps/server/client/components/layout/header/index.tsx b/apps/server/client/components/layout/header/index.tsx new file mode 100644 index 000000000..26d098403 --- /dev/null +++ b/apps/server/client/components/layout/header/index.tsx @@ -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) => { + const [state, setState] = useState(value.get()) + useMotionValueEvent(value, "change", (v) => setState(v)) + return state +} + +function Container({ className, ...props }: React.ComponentPropsWithoutRef<"div">) { + return ( +
+ ) +} + +const HeaderWrapper: Component = (props) => { + const { scrollY } = useScroll() + const scrollYState = useMotionValueToState(scrollY) + const showOverlay = scrollYState > 100 + + return ( +
+
+ + {props.children} +
+ ) +} +export const Header = () => { + const handleToApp = () => { + openInFollowApp( + "", + () => { + window.open(siteConfig.appUrl, "_blank") + }, + true, + ) + } + useHotkeys("l", handleToApp) + + return ( + + + + + + ) +} diff --git a/apps/server/client/components/layout/main/index.tsx b/apps/server/client/components/layout/main/index.tsx new file mode 100644 index 000000000..446a94196 --- /dev/null +++ b/apps/server/client/components/layout/main/index.tsx @@ -0,0 +1,14 @@ +import { cn } from "@follow/utils/utils" + +export const MainContainer: Component = (props) => { + return ( +
+ {props.children} +
+ ) +} diff --git a/apps/server/client/components/layout/main/teleportal.tsx b/apps/server/client/components/layout/main/teleportal.tsx new file mode 100644 index 000000000..d8ae490e1 --- /dev/null +++ b/apps/server/client/components/layout/main/teleportal.tsx @@ -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 ( + +
+ + ) +} + +export const TeleportalTakeOff: FC = ({ children }) => { + const portal = useAtomValue(portalAtoms) + + if (!portal) return null + + return createPortal(children, portal) +} diff --git a/apps/server/client/components/ui/feed-certification.tsx b/apps/server/client/components/ui/feed-certification.tsx new file mode 100644 index 000000000..4cefede42 --- /dev/null +++ b/apps/server/client/components/ui/feed-certification.tsx @@ -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 ? ( + + + + + + + +
+ + {t("feed_item.claimed_feed")} +
+
{t("feed_item.claimed_by_you")}
+
+
+
+ ) : ( + + + + + + + +
+ + {t("feed_item.claimed_feed")} +
+
+ {t("feed_item.claimed_by_owner")} + {feed.owner ? ( + + + {feed.owner.name?.slice(0, 2)} + + ) : ( + {t("feed_item.claimed_by_unknown")} + )} +
+
+
+
+ )) + ) +} diff --git a/apps/server/client/components/ui/image.tsx b/apps/server/client/components/ui/image.tsx new file mode 100644 index 000000000..a7bb3d37f --- /dev/null +++ b/apps/server/client/components/ui/image.tsx @@ -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( + ({ 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 ( + + + +
+ {blurhash && ( + + )} +
+
+
+ ) + }, +) diff --git a/apps/server/client/configs.ts b/apps/server/client/configs.ts new file mode 100644 index 000000000..d9856af09 --- /dev/null +++ b/apps/server/client/configs.ts @@ -0,0 +1,4 @@ +export const siteConfig = { + repoUrl: "https://github.com/RSSNext/Follow", + appUrl: "https://app.follow.is", +} diff --git a/apps/server/client/framer-lazy-feature.ts b/apps/server/client/framer-lazy-feature.ts new file mode 100644 index 000000000..b61a58f03 --- /dev/null +++ b/apps/server/client/framer-lazy-feature.ts @@ -0,0 +1 @@ +export { domMax as default } from "framer-motion" diff --git a/apps/server/client/global.d.ts b/apps/server/client/global.d.ts new file mode 100644 index 000000000..2631d3f3f --- /dev/null +++ b/apps/server/client/global.d.ts @@ -0,0 +1,13 @@ +// data hydrate + +// e.g. window.__HYDRATE__['feeds.$get,query:id=41223694984583197'] +declare global { + interface Window { + __HYDRATE__: Record + } + + export const GIT_COMMIT_SHA: string + export const APP_VERSION: string +} + +export {} diff --git a/apps/server/client/i18n.ts b/apps/server/client/i18n.ts new file mode 100644 index 000000000..317e32fa8 --- /dev/null +++ b/apps/server/client/i18n.ts @@ -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 + } +} diff --git a/apps/server/client/index.tsx b/apps/server/client/index.tsx new file mode 100644 index 000000000..325e148df --- /dev/null +++ b/apps/server/client/index.tsx @@ -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( + + + + , +) diff --git a/apps/server/client/initialize/analytics.ts b/apps/server/client/initialize/analytics.ts new file mode 100644 index 000000000..963a85570 --- /dev/null +++ b/apps/server/client/initialize/analytics.ts @@ -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) + }, + } +} diff --git a/apps/server/client/initialize/helper.ts b/apps/server/client/initialize/helper.ts new file mode 100644 index 000000000..1b5073c95 --- /dev/null +++ b/apps/server/client/initialize/helper.ts @@ -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) + }) +} diff --git a/apps/server/client/initialize/index.ts b/apps/server/client/initialize/index.ts new file mode 100644 index 000000000..ad190c55b --- /dev/null +++ b/apps/server/client/initialize/index.ts @@ -0,0 +1,10 @@ +import { initI18n } from "@client/i18n" + +import { initAnalytics } from "./analytics" +import { initSentry } from "./sentry" + +export const initialize = async () => { + initI18n() + initAnalytics() + initSentry() +} diff --git a/apps/server/client/initialize/op.ts b/apps/server/client/initialize/op.ts new file mode 100644 index 000000000..5cbf50cc2 --- /dev/null +++ b/apps/server/client/initialize/op.ts @@ -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, +}) diff --git a/apps/server/client/initialize/sentry.ts b/apps/server/client/initialize/sentry.ts new file mode 100644 index 000000000..9b108ebad --- /dev/null +++ b/apps/server/client/initialize/sentry.ts @@ -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 + }, +} diff --git a/apps/server/client/lib/api-fetch.ts b/apps/server/client/lib/api-fetch.ts new file mode 100644 index 000000000..64ea7793d --- /dev/null +++ b/apps/server/client/lib/api-fetch.ts @@ -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 | 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(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", + } + }, +}) diff --git a/apps/server/client/lib/helper.ts b/apps/server/client/lib/helper.ts new file mode 100644 index 000000000..51226f578 --- /dev/null +++ b/apps/server/client/lib/helper.ts @@ -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 => { + 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) + }) +} diff --git a/apps/server/client/lib/query-client.ts b/apps/server/client/lib/query-client.ts new file mode 100644 index 000000000..78bb002e1 --- /dev/null +++ b/apps/server/client/lib/query-client.ts @@ -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) + }, + }, + }, +}) diff --git a/apps/server/client/lib/store.ts b/apps/server/client/lib/store.ts new file mode 100644 index 000000000..991df8399 --- /dev/null +++ b/apps/server/client/lib/store.ts @@ -0,0 +1 @@ +export { jotaiStore } from "@follow/utils/jotai" diff --git a/apps/server/client/pages/index.tsx b/apps/server/client/pages/index.tsx new file mode 100644 index 000000000..a67d030dd --- /dev/null +++ b/apps/server/client/pages/index.tsx @@ -0,0 +1,9 @@ +import { Link } from "react-router-dom" + +export const Component = () => { + return ( + + Hello + + ) +} diff --git a/apps/server/client/pages/layout.tsx b/apps/server/client/pages/layout.tsx new file mode 100644 index 000000000..d35a0108c --- /dev/null +++ b/apps/server/client/pages/layout.tsx @@ -0,0 +1,7 @@ +import { useSyncThemeWebApp } from "@follow/hooks" +import { Outlet } from "react-router-dom" + +export const Component = () => { + useSyncThemeWebApp() + return +} diff --git a/apps/server/client/pages/share/feeds/[id]/index.tsx b/apps/server/client/pages/share/feeds/[id]/index.tsx new file mode 100644 index 000000000..c36acd813 --- /dev/null +++ b/apps/server/client/pages/share/feeds/[id]/index.tsx @@ -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 + } + case viewsRenderType.Picture.includes(view): { + return + } + case viewsRenderType.Grid.includes(view): { + return + } + } + }, [entries.data, feed.data, view]) + if (feed.isLoading || !feed.data?.feed || !feedData) { + return ( + <> + + + ) + } + + return ( + + + +
+
+

{feed.data.feed.title}

+ +
+
{feed.data.feed.description}
+
+ +
+ {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, + })} +
+ + + {feedData.url.startsWith("https://") ? ( + + ) : ( + + )} + + +
+ {entries.isLoading && !entries.data ? ( + + ) : ( + renderContent + )} +
+
+ ) +} +const NormalList: FC<{ + entries: EntriesPreview + + feed: Feed +}> = ({ entries, feed }) => { + return ( + <> + {entries?.map((entry) => ( + +
+ +
+
+ ))} + + ) +} diff --git a/apps/server/client/pages/share/layout.tsx b/apps/server/client/pages/share/layout.tsx new file mode 100644 index 000000000..79116009a --- /dev/null +++ b/apps/server/client/pages/share/layout.tsx @@ -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 ( +
+ + {`:root { + --container-max-width: 1024px; + }`} + +
+
+ +
+ + +
+ ) +} diff --git a/apps/server/client/pages/share/lists/[id]/index.tsx b/apps/server/client/pages/share/lists/[id]/index.tsx new file mode 100644 index 000000000..b783b3327 --- /dev/null +++ b/apps/server/client/pages/share/lists/[id]/index.tsx @@ -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, + ) || {} + + 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 ( + + {list.isLoading ? ( + + ) : ( + list.data?.list && ( +
+ +
+
+

{list.data.list.title}

+ +
+
{list.data.list.description}
+
+
+ {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, + })} +
+ + + + +
+ {listData!.feedIds + ?.slice(0, 5) + .map((feedId) => )} + {"feedCount" in list.data && ( +
+ {t("feed.follow_to_view_all", { + count: list.data.feedCount || 0, + })} +
+ )} +
+
+ ) + )} +
+ ) +} + +const FeedRow = ({ feed }: { feed: Feed["feed"] }) => { + return ( + + + {feed.title} + + + ) +} diff --git a/apps/server/client/pages/share/users/[id]/index.tsx b/apps/server/client/pages/share/users/[id]/index.tsx new file mode 100644 index 000000000..6a49a6566 --- /dev/null +++ b/apps/server/client/pages/share/users/[id]/index.tsx @@ -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 ( + + {user.isLoading ? ( + + ) : ( + + + + {user.data?.name?.slice(0, 2)} + +
+
+

{user.data?.name}

+
+ {user.data?.handle && ( +
@{user.data.handle}
+ )} +
+
+ {subscriptions.isLoading ? ( + + ) : ( + Object.keys(subscriptions.data || {}).map((category) => ( +
+
+

{category}

+
+
+ {subscriptions.data?.[category].map( + (subscription) => + "feeds" in subscription && ( +
+ + +
+
+ {subscription.feeds?.title} +
+ {subscription.feeds?.description && ( +
+ {subscription.feeds.description} +
+ )} +
+
+ + + +
+ ), + )} +
+
+ )) + )} +
+ +
+ )} +
+ ) +} diff --git a/apps/server/client/providers/root-providers.tsx b/apps/server/client/providers/root-providers.tsx new file mode 100644 index 000000000..c0a530894 --- /dev/null +++ b/apps/server/client/providers/root-providers.tsx @@ -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 = ({ children }) => ( + + + + + + + + + + + {children} + + + + + +) diff --git a/apps/server/client/providers/user-provider.tsx b/apps/server/client/providers/user-provider.tsx new file mode 100644 index 000000000..8818936b1 --- /dev/null +++ b/apps/server/client/providers/user-provider.tsx @@ -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 +} diff --git a/apps/server/client/query/auth.ts b/apps/server/client/query/auth.ts new file mode 100644 index 000000000..a7d38714c --- /dev/null +++ b/apps/server/client/query/auth.ts @@ -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( + path: string, + + req: any = {}, +): Promise { + 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", params) +} diff --git a/apps/server/client/query/entries.ts b/apps/server/client/query/entries.ts new file mode 100644 index 000000000..9432c1b4d --- /dev/null +++ b/apps/server/client/query/entries.ts @@ -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> diff --git a/apps/server/client/query/feed.ts b/apps/server/client/query/feed.ts new file mode 100644 index 000000000..d347ff0ee --- /dev/null +++ b/apps/server/client/query/feed.ts @@ -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 + >, + }) + +export type Feed = Awaited> diff --git a/apps/server/client/query/list.ts b/apps/server/client/query/list.ts new file mode 100644 index 000000000..ee970089d --- /dev/null +++ b/apps/server/client/query/list.ts @@ -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 + >, + }) diff --git a/apps/server/client/query/users.ts b/apps/server/client/query/users.ts new file mode 100644 index 000000000..52c5c7019 --- /dev/null +++ b/apps/server/client/query/users.ts @@ -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 + 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}`), + }) +} diff --git a/apps/server/client/router.tsx b/apps/server/client/router.tsx new file mode 100644 index 000000000..5003b4388 --- /dev/null +++ b/apps/server/client/router.tsx @@ -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: , + }, + // { + // path: "*", + // element: , + // }, + ], + { + // todo + // basename: "/test", + }, +) diff --git a/apps/server/client/styles/index.css b/apps/server/client/styles/index.css new file mode 100644 index 000000000..bfca78af1 --- /dev/null +++ b/apps/server/client/styles/index.css @@ -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); +} diff --git a/apps/server/index.html b/apps/server/index.html new file mode 100644 index 000000000..afc01c010 --- /dev/null +++ b/apps/server/index.html @@ -0,0 +1,54 @@ + + + + + + + + + + + <!-- TITLE --> + + + + + + +
+ + + diff --git a/apps/server/index.ts b/apps/server/index.ts new file mode 100644 index 000000000..ecbee4988 --- /dev/null +++ b/apps/server/index.ts @@ -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") + }) +} diff --git a/apps/server/note.md b/apps/server/note.md new file mode 100644 index 000000000..19569a6ab --- /dev/null +++ b/apps/server/note.md @@ -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 diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 000000000..4449d605a --- /dev/null +++ b/apps/server/package.json @@ -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" + } +} diff --git a/apps/server/postcss.config.cjs b/apps/server/postcss.config.cjs new file mode 100644 index 000000000..f7cbfabcb --- /dev/null +++ b/apps/server/postcss.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + tailwindcss: {}, + "tailwindcss/nesting": {}, + + ...(process.env.NODE_ENV === "production" ? { cssnano: {} } : {}), + }, +} diff --git a/apps/server/public/favicon.ico b/apps/server/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..89db74809d3321e3b97ff5c103e1678f37c84926 GIT binary patch literal 15406 zcmeHOdvH|M89(>lO(Gx&G&JL5+KeyI>I^Nk0-{YzBjpiL;7a}A`DTIfL(8w4m z)0PpCwjwePK5?um2r=c?76ckc6l)qpF@Z^J=un1nK3iyG>=%r&{QU6qs9}t~hI@_x_HPj(b-1bDw1;Dck4Fh~zxnv*u&Js70o`gunB1y}pdbn6$~?R)g}<3KJ5Zr?E;{elNz z(aQiY;rF2MFLCx0*>_TJoUFvTh9LXD;L$(-g}(2Q&ku0xqj6x60~Z+0YobDr{tG<% zH~*_oKc(oq%EL!>1jT>RME_Sja(?y%Kx;=B!|7Upt4mX)U-5J4CyX@rC|CzDU>?72 zOZ&+;C_9?+mjOKW7Qn{$^=Z!Y=vVBM(R}*PCjQsaPxf7GjBIbcrKFP`*_0>|BTgd+W%$a@#p480K8rN2xyHr;VFIndi@V23s&EsJ5~X#eiL8~+GR!+ zRRFYJQ`;^g#_td5ithjPEGXU85p{LO!lcXPzzI^Nr(aY8Zze6@0<$0ocj@V-JW27p z(ybrYZiuWN`y{wU%Swfx_lQi9oGDsH54DCt9&cTy4{JJsQ2IkzoeWX;lZ>lu? zSgAPU^a}C*Gpof}XZ|2QP*V{iHLb?le9F_3T1N3qEF)PcQ<`zSOq#rZT6V!pqw?;1 zdQh^s8LN_V#%>-dPCve$Ia-c0x9?jfcefyUne1=JxFdDsN0}(w?Q6ijt`p*n6C1O~ zZMjW*ZW5P0_Nr`Y%Ev{_(a5!TgWqNSd1*5Fj+W1)DWBXQ>Z5UiXM*jwpH61Zwo`~J zm=JHZOS zV zjQ8+tc=$piT%7rGuw8U`7a)G!J%%2;ge=)xmMH!azXLc|-wVAraTNC{uV2E6)3B`( zzsvh@I`NbL$=A_g_aT6x3%*DE*j9=3AL6-fWw_6)Hq>1Xp9S&n!yDPj>3;?Ln36bt zvVnX}cR9+o16-GMzftW;J$@1Q;0NO#INg&lqnr{ILozWh{!UB(Q(u^kcS{bO12|KM zbjh5m&jUQU3qV496YZCwKEd<;D)^3@^dA*_lUMEG{PEVk$$ZOr{cnzc;C$ozI#!mP zZvYtiSfXu^8vNAH|8c+yseXAap4ZVmuf+OLYVgxMVEGP!PM*>W-|-#$0dB`L%fzq~UuyiX>ZdXBhH`-X<<|70rQvZ_dE-esKNi0-fW@Jv3LK>cs~ zKCAmaT=mLjmaHzd)B#zk>u(uLi`EupQH~zp6R;VXBh(TDadkq$0CN>G~zIM zN6p$JcqvP6A7_Psn7#2D#NPk*4;83pZs5ABD=OWO^*8^EGE|`N&Gvr!n#TUs0Okp% z|IQ1JUz~nAiT4%aJ5Q2*tj!FT!{+-l192OCBi4I^~$Z6ys$P6wV8mYk6ek56db=ISw3S?qv@F a{v%EQE?PqHGfWu|WIT}ZK*j?x9{4}tV#~Jx literal 0 HcmV?d00001 diff --git a/apps/server/public/icon-192x192.png b/apps/server/public/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..9eb8f5d5eef9300cb8f8518448eca2d582307587 GIT binary patch literal 7589 zcmc(E)ms#f^Y!jhyL5wuOUDNg1cXI8q`Ra+8l;h~1!*M(0YSPYm+qycTbd=5lqi2ykg|0RRAjlA@gUKSur!VC;WUd&LRoA7R=_t4RX@ zwF!6+@38*m5I03bPXK_D^?v{=X|tRF0Q3qObc+54^pE@1#f)dRk@T3l zD{e9FLH|hz{-ln(AGRG8kRU5i`Yc~}HyWvw9mB@^cc!ly9;+zl7IU-m`vpfBSlZCe zz!na)ZK*>AwC#VNF%|cuM>5FxtR3A+#vWC*2sE|+eJB|(+tLNm(7cRfgUeUsL2^mn z+k@=;XubhxYX6^=`oaJnOmgFY;*b8|w{+OO^vZk~WYosg3>zOhL2xW~(D0wdwYKH{ z{jxu;LoQ^e5JT;!0bh~Nj~tSLJPt?*K-jjvPVMED63HzK-Sc*!XpZQ-i zh-Nm`t^3qUyfLe?IPTxb+RJ!#N5%17ZBMRQrb{Qp5$C$iMDQjGwbgcU6-D}= z`}aA`rOfFm=siJu8Yv)&pNqCHuhyb3I4U{L}APtHkux|^EMbQ@!=7>=rjfU z8V&Qu)ZS#d7#<0lThQkmG`r>I?bV}EXvr+2x7EBvF%}QM8OJG8NF@IHQg1uLA%zOl z;*hC$iTjsfD^z|ylOu>gys?G-1Y2S;d;Z4y;Inqk)E_H7oz~=Xw6bH`9?Q+s8##kv zwT;HbMzrh#u|KFrUdt%w*fn=#f`g(A>e)%@8?wRqEOIBXj?R>Yfj3|1PHmV2UF%!Z zn}5&JV2aHsUkYJhZ|-r`;FrgBkd!eTD22Kg`CpaGyPZ5>%j=`o3~YU}oHfY*lnNUl zozsPq^*2&fIn?qeaTA#zh>}h;$juoyLh!5Rt}!JcaW$dK5fNE2Zh%YdW36RVc@|$O z^-=1K7`9}c%E=^#6im%4X1piiGNxnQMUz^ilU{-&lIFOqRru0Tuw(CrTv*NsAnN)?Z(K_?xCb_N)d1hHVjzCEu+Lp2HtUj8|_+SDcU1m2B(RS$K1?I~b)f|Cbg@AnAuMPNKI0`mw(fePBhK%niVCwF<#hSU8 z_MHo+J?)!42t>FdDj(4>1~rA9$rn$m(Y{a?V=1=Y7ya4I5zNwz^&ylrw|%nqwC}C`oSd;JZ#QDXoG2$hNFSW0iaoXPD^hZWkgr zVo_x;*u#MYbdD89)|}_klqR^zl3FmoIe-(NlSc0GP{UXd%yne`)%3&bKE{cgH9Sli zxVlGRpY=k+PG*7HJNgnt!#OP!R@WDa-1Qr%gn6%9`5vx!il1WzGZBNxzcw#*@;l@( zO`;wh&e_dsN)O3wipAoN%w-+;D5dyhga>W>^B6U>MV207pjkrfWH)jqgnEnL0H+_V&c12{)*UL4DLIA0|IbkfuU^~oP|zOMFs)hM^009%wCjjL^FCCKVAFHS#oXyzEI5=w z>st-I-!7F6+2D~!b$_v7#Nfw%*NRIWmYdlvq~RK7dxs(s#J1F^%hkG2bmcz;!>$nnz*Y~qdMW&G$L%ajK`=)Mv-NDWm-b61nEH0H1 z`=kTNG+)>U_OL3k=_%p8LW{g@>p5QNUlF_AKpY6B_o>={6{YkJKK=n^H7_CYy?*o7 z_;#M`!k#?GC*t4Q3zPF?dI?J)ec!r1(gbx)A&Z{=Y>R}EcIF@Hrz2PSDMQf$shNo2ZnXUmsl1>;ZzN9E z$D|_mayZEo>4^swRg?BHqu!H8Hw02CJ)H!>dnZ1ekETa(@TaKLcs}C`;7?TI3K_s@ zpqosWVUV#`d?rB_JqFF*e7(!^YOmph zls_M;z)$;=`RQF0)0_o}k0kOrCN~S{W>&W)YUvIf0-3C;VhaD{u(0??`?AbEr}wQ) z$U4N*FfQQOM_kU2oMT`Yvca9~=)m=>#QzQ_XJ6W1?!4s=;~5q%Ycp^Gy<)N>5tW$V ziQLZ;uHF;b93<-Nf#?&);}?W>vk?p5oHe2BNOWiiH9!4!T=TTEDKa-Jvy>`SW6(>> zBj<6kmx#H^;RTKnNcAJ1NAbO|=(YoK2VfY~WwARwFvEAfE+L^efV}z_%{YqMUQR9; z*o7xJjR zUIhtt(!(A8@8m5@TUu)PG}xots5-seW;P0`rhu{bB&u1-1Vboo@tssISf5lOs{XvWh2)~b5Pl9eIis~WpqM(v@%Yi@%ylwa zi6r}Cdd_UWk#ND0=--m~sS9uJs=v66`)2${Xh|?0otH$-6GUnZEOAQd_o<3}vIgJa zfobtUAT-X{bZtNoM22?Bh^{AEuf&qchDU@_xm+_c3H|~KxD@OxulMnS&kI+(K$-cQ zb1;H#u@@T6B)ryc1>`7!?xaK_TOo1`~1e+ z`1&q$s=>j)QtrH?u?-eCLm?a)2H$ZruU=GPZ?Gc%;N)Xuk{K?eq&MVwB<|BxwpT<| zJ~_t^Z}yId)!dEBj-9i@M!;C5oZ@nvd>WWRKVf3GYFkmXn@+QmVgpWfSs8}=%A3jU z0t;yt1uB~Dc8Rc#o3J|HPXio_0o(m0Cl^2$T7zsho5ApBa z925<4O2{p-TjjJ;AM-y|zfwqye~^_lONH*f5aL-_e0-o@?->%$H0|j;|1>gKH+4W4 za_VTY$Auh8z#{AOCSocj-^M6v zgnM;mO~h^6hwwim6iss4vzndj3M2`};x%~Nm4wvV0rE;Hcfex?2@>v{7O{R|+&FpH z6kCgkJm6o8nA0m(FA++Puu6p!6S2ceAGe&2YH~y$+^$6qyr1INohPB*HKHsujEB4! zj$bUm>(^~du>iAQ!ke5xu@rq^L?3*d&|EhCSj)GX*a6CrOMT+k;o+$ey|5qc?D8O; z?HAStHq)I`TGczpPEFdG?k#El;8i;4?1z4V0&k1|mIh2f%*3Ivi~0u&8rP#R4Ih?U zpYgNU&ro69s894O|IvDOS70$Z zT(B$p>mC_j*aa}`blt!cfgSWfBy|Y#kyYIt#~KmI^!=mEAJB=@{(6KW7>C3oIDmOt zh4I&^Fq1vc2+KNSAJ!%@uLwTuFWq`N9_kCp{l)$w*@4|l$wqcxftu|gi`6xFQ*oQI zFnt~S#2=DFd?qyjt0q5w+@P&S45G+9s7I~hoyvcyA3s}wky{R~*h}2=iknl}(Up&D zFDz@xWiA1R5aY;=dcZ&uOBt6=qa>c;h(0Fa3&Mmmm<=MI_W@sHQSKW6R~t-`BZ{9W z!u%f-9Y(K-fH!6oOcIFdA>Kn8Kyx63y>2SJictG!7z*B0lk~KL)1Nka0_7L7Wn4B} zRybTEV;|UT4(cDTCJ(9P{n-rqv4)oj*FIhYuLKt|#;sAzX$E!f9A{ymx9fghjz)(% zFZ4GIev5TNKj|=001Nr@#k1LeL1uXVsSz9G0v=pe9RnK*NYQPU{jPS2jBPp6-1#kx zisV%fXVUc}jvnAlYLBb_9`Fc$|61rhteAb|^xyGMLJi-$-nRhnLH7wj^{#mEi5TCO zC=$VXN`3agBInN;?!zXln=1Jpjqlo3NYxR{=5~|e(pjXMyAt7JmZiZqx~l^>=fysv z!)fQ^`O5OYHjdAc$?s^eQEG1bnM+l^Gb1qFVY z{9~79_QSO%YS@K}OZMrrJNE=Jg)9PYMo<^|dO1T^0v#-d}k-w}B8U2|4jWDq4y^<{Cx zfIIR$#V{>mHxA^LDW0Qc<-?1oF2{Q@G|n)r!svU?Gm&U?q-x-j+xKup(0tMUX{6=p za}ewYHafsL6ScVMiyyJ{Ralh9hP_$MlxMkS?@hr}x!L9(!3gTRN&B3_u<12MyNkyd z(>xtVd?=YJBJmPTYy-{JQ*-iOa8xiLx12*D&!(f);*bbocyDKgZMcs+%OLZ3kKKw= z9yP3+8_(3GF!5+eyK9{b*sh~3Di;Pxlukh^-$k^M1HHBEI~La)pv1uKQUL%{)uX9_ zLz#@_n?;CONmV5R8A}c{aJ$|7*_4ARaU;3D&KulXL`>+hlCKc5>)2B&AF?0%=c$&v z-ZtC{>U8OM`=uHaX4c^59F@V!`YhI}&WCFuxa0l@vRcxOsCL1S)K?+zO!cI$3By&4 zVr6H*X@J?CfZJNb6kgyxuaxW3Y9eK*0VYs2ydz>(t;mo`3NIhq ztk+ywuhsJ9$;T-q#Wg7Bet1Cg4}mA{@ve?UJ2RGQ8_@Ueh0n4zrbgHrFe|O_IYUF~ z&~EZ^Jo)cvy{RK0d;qQ|E0EDk5(W?p0JKNud^~Yk1V8*$U61AkSalH|gyf61?Z=%J zio)-RLVavOyl7sOtL|wG%kRbqMympcqsTCPespFan#=;lpZ@^+G1FGBiYcdB&+8I-GEclz`bG;M+t1?3kDudBIL1z|=TW3o=C@b7MWg=tJ77a+E0h@_Pz4QkEMBd{DrZZD_uLpR+7DKV`oG`9MVmwodA-}NOv~Uf&Q3t$q%II?! zrum`$^IOP|Mx#M(4h8Z#j7KiTSL4tP^<$l|_dPW%{O_0h9WBWElF;+&e6`XVL24!< zW2KU=rFv<__a>#t`yh{f4WeQ0 zBOUlhLte+d1gdr+4V#*aGOrjlPhNY4K(L!b(Tdw_sye z(ad?>$;dAs4bSN(<7=8WiQ+42x1EKyLprNx4N|1ygg#ng4WK6$?2T*mMmy|X1-Rmk zpGW(U8wfra$8*=Khb)-{t@7=Uy@O;8saQrFYrPW;gU`%?w!aE)Pm~Euw%!hgzk2Mo z7$#kDC-$NL&hz|L$?aqDsHZjd_p%5{iIdTsqA|G92f_-mP*SOt#$WyyctJ+l zCUzYjiba=q>!frV9q^*DJo~WCRmNGp=k=zr;ncUG!|IdIU}|%SyNGUCHXQ zZd0&&hq%{ou%GX=T+DNlSQg)vwR&d*URPowBfh_cRaH({uR%Pz>zLG!>$valID=@+ zjb=`dq$%cP2YwB50o357-L$ucD=fC{HQl~eqe$yNwRQU#=3ojKkh+M>MI-2F;%#M) zmfZx6E(_7Z4}7b7PI7wR;)zH??sPM*=i)WfCYcqli?Dq8ck?4Y0_*}0Jwy((HfZ0s z&Q%5aHn<;$#3OL@DJ)aiq|v=(XkF}mXyvP;7s}CFTSYdsFS2f}Ij#aD!vLREvP;Jv zY@Ga1t;LN91^rKwrmwYECrbdbf{-(+>=PgBT0i;kH&#|@1~3Go`3gbR6wda~Yl4wC zcOyK`J~^80fkE$EcQ4B zT0Y1Hht}Y_Ly$|!E6#TD0)4S$MBi^9YfL}wT~7VR_5c(w^+Z5M0r0cXfoR0=$=%Ot zJ~?Ww39&bd!DVwlgs~NC^?tLZAENssPOtHs$ebLQ5+yEx7qTSt7sX>amv#BOf4b0M zv5Bum?n*N6bbE0kvS;rwPLmYwer0}~`pLD!YF|enxTdCSY89=GvGi5Qs-N@YW?4Ym zhVP72r~)RsT`2A$qUnbrMB;kXo8IA(il(pbhqk3dmu2mLT)2|ubb;<8g&Efo>gNGq z9w2o=^%Lb=6UVdDkCfL`nVH(pe%K5v8-_?l1n#dnZl>q4-n0vNjt(+n_~#$*uD-iQ zep=fw5zE^#9+98F^KO@H(rP~o{&Hhi8{>uXX>9AzwttCs)#(89NH(3s5#)O_wST!M zIw6LbTdDuB50@J-Yj9q8c=yRb0-@XKYmh4DB*#g}sbN_!@UP_|2P}Ezi8ULa&0UH^fRU$>2-s&@AFEdYcxF_BX>68p8weRr#OM&mE~f ziQ87|k_c5Cf2ogj+C|W28!&%OO+PClv7uI#qkdUxKv=D4$#9FSr-V_|exYB@kMc^q zN;ITBq^E3bw4SWV)A?1Y{_e7ze6G>Dwq=3bxEwdmpL4Wfv8SA8EucsOqdh)QdX*U2 zdu?9%=aM_oor-GR zo|eIQqa;h2+^DRrOf6=a2S!9GeC=s$91$)BMh%D?}$*(|-nmz!{C(hgD|bn(&Jl>s?qubcVe`R-jLM3Se@u#f!CWoFt~a z#X=;8i*eotv3Au7Dw*Lsy7h{Wefp*cV>&QY(SA$|n4Z6?_fN>2&n@XPoYEp zf{UySsi`8NY9>p5Gg!WuY%raLQAh>s^83bLFsG zkPr~R+MUlFO|2FSI$`sD+tncZLQmO>tc>PidY{_>!fV=Tu+U# zk^>A)v_pr(XdJ9@ZM-(@v+g#Xm!HAFE<1DCGMnHUW_{O5%8l7Eh(J>0wN3}DJ3N(($bCO z5OW8=_pi9m{efrTnX}K@d#`%eyB2Z!x*BA}%)|fykUe^+W(WXa+^=AO5Fht(6#Nr| z`+&MC>nH=jrzDa~2Rz*0L_QBK`~iTT>*fda$nfqy00hN6Qd2e#&f1D!3f#yK?ceB@~;VFVX%FU$Yby4iu|$Yf+;*TTI8I8K8%X$ZDKua z{cY6Ux6QpLRz+D@8FBwQlGy3=)~_MnyQeREcYk=j8=Ot|M;WcM+)`6h`{~07#Ky)l zQ$heLDg(%4fSr@m14atqz9NeSA!1`oK%xL871e!qe1MZPAHNfT;T9qO|Dyi~F2W;4 zmY6eCgItz($$tw~k^y&kJ^y!g&vm^`_>0H2mBy&s^^9@feEM#2!?lv@{%OIIDmt26!Wk7gJ+G7MS#oeZ1PixnlNSzkFIS9 ztMlH4Z#k~>QPsveRkspiS*fnWI=JWdPcz{fOe1Y}n_G<^DgHh~c8%Yg>$VG+zQuRO zF>;#zroe9{!(j3DW5uSdMZ~BBkjoKob&Tw@-FPe%OTy+4^**y4>pK`JYO6JY2jwe% z{!g)c%duAR*K&al+w##!MN-k(_pLj(SjAct2j^XT;KjW5AEd&pkV9mtL=5h`1pMRk z4SDp_1y_62aVo0O=R&h(msfn3{`lB(9d&OMH;qNB2>!Z1W=?eN;@IF#l5H66(MV+y zYgonYfPi&Jq@roJ-DKgOIE}!ZPg3Ot{c883G&vqARKl1PIBNJg;y~bRe6UHeYd;W&SzbzeBV`z4GkFupGDYZk@J|g@p%28CV_SWvlkDlyjbpdpC^q{J^9YB;5`esm04Ty~0>7Zt7@62WOs&M*aQFxpA7> zP+QPgw$i6Vws@J@_JCAYyD6QP4ZEwiK6?7c@G1zUm_5-0!8}taR{iE@Z~vWxMKN|) zZ%l6AJA|r46lXItrkG70ry~oa={+X4qS2gdNw{|5vxS8BS99jMpGFHj%2%FvG|kT} z#343y*0iPF;*)lceAOF*uBkU}O0TGj%xFMrPn(Sh~$Gh^@Ezq;|B|Dx=*tow+uj z7BVtW`P@60C)B3RreJS_;CQBOQ)xxGc@&NZaX=HZ8MJVY%Ml%eb$j90Cij#O>wy_`|=|Jcd`9tzQNu6a^FtQ8}o)clts`vGIHbStV ztLtOTv)PIc^ww7RnDrEKd#YK9C97c`pCjN#xoM<=HY-{><&RTJ<8P!QOq|$bEhUMu zc!_Em{b0u=_a{)rn2pb$fDfMlEY9={_fBaIGfX2aR;zPm)7wBem-r=%_8D(3cCz0$3pK{R$QNQD?}RxRlpYDzMC;Y(F|rWi$AHB1%n0CtAFGbo zChd-IIj>aHrqaF`NG&OFATY$xzeM9Ffb1bIeBtI|l&R2zGnBM87DE@-;ys+2u8;N# z%O{)9>_G4`479e@U9YPw(xK=Ey70hR@nT{m~}a~+d)lc}eQOt9y^6--cPcQqr(hnmwv3J^QEW)LvS-kF8hhC{oWzwpo|Oe1h(1bX zU7IR9>awqr*0RkDcjtrCfCrXTmJAgYoAT^|DOj4ui%z5i-rl)?my6*%8gr%BnNu~e zj8&e5|+F{Syh@h9})CQKwV?4h1cuL4dY1R7httJM=S!Nq{_)mQq=S5 z@cV+pvRI*Dy#J1BC*2(9to^61&!nKwiAarv@{b$W%T_aoUPFSIpRA1QhpFtG%yLK# ze4DWCwCK~=kT=H$zF)9uvJPr zX^SDRoUG0%Lj1dW0#@K!=C5qJvJsYDfPO)Xqkm#k#d=E8r@2L*Q=u>qKJ#-LgB?pL zVRR&>E~Ngfsmbul;DbmnJwvq%VTm#+aR1@$S2c%wO5MM@4Xzb1Kb>k0x$}RyzPi^z zX-{%Y@T}_WW7TD~Ne*pd(}OR$8SQ|+B{#mlr%;Dm^1@yPk)A8? zwB$(uAz>%o=8Hh<@;gEs4umBfI3a4c$&JoKq8+ydYx}-hks4oL%1{|7lx_>Tsh|UQ zt)JIaU1B>mpifBnQ<$k(04-|@?`eZCCl!2iMf}(VbYD*FW-1YA#0QAXoGqwq?WDV& z-K^V5Y$|!L0J@JrYLV&pJmeLkxQwC5$;jkPov-4@R{mQxpDP2p)=|*tJKgNV%AB6v zn>i0t3ITUB@~4Rwkw=-|YaHHdjk2lj7am7qy%wUBIDh#VOY%N>(h!-M%8~+ath$^m zbIiS^_*>3iKoofMx%YiV0{ryd+K(=ZyZb~w?QB#}gWJ0z10b#^_rA1$#$1KvY|hhp zRKtmU8JEP%%^oX=yC$U%fnI&3`2RdpIUmQf=|EGR`NKZwrPy*_~Hc zGoYl$zuEN5u-U&60p9$#usll5@T}>B#&n%{`&XyoVrXovr1s*S$P|4$eikm)-kiqE z=7o<5x6jKbnZVru1kI@5^MiJ`h`U5)20PBoz1yap;{oEnq8fQMB;nI8gv~N$c*e3Q zkGq`Ian~aAB`N-RUnnO9?GW*q`ZDN(ad@b@U;Ovb6t~}5(-<44bgYQrs z3ZFlTPYPs}8~14Ql!*a9AcCOXEccDicm|Z;zd-TlSB6a=uURd!cn5jI#gwFCNg>ND6PPgYjj!Zis= zkFH1(i64(WE$*(t*zaL#-dmO*f*1;BiGIA+tHa5lRl;N;rJr7Zo6tG$pAguZg1^fO zp7E%nmTor(C4gmo^JY)pr_Q48_$KQ%v#Chz4ozmq@`UrDx5JxF;<18P*QpgQ)wP8p z3GAjSceHI@)Hbary1o>jZ>!~X4TMW64t9md#^QI{6IltgHJo&8RPp1-RKHPOS3jHX(hGF5m8AFgs5cNl@_SbIEW42@l zozv_U#efBdqE)AJ_|h1)n%a?zfiR+zJC&6$T=c40odR`IDp;u_6qe3mAB8)1Ztmy7 zZ}~L+Gi?k#8^Lm9o>w$ftseqgfmd8KB?&3{@juxd7Z!uQ0dU(N#cnEXi-%9H1ce!f zst|h;Rh?<5)hjv(Bd)u$Q^*vn=ao?FH4OG2aeD#wqLN<6Ovopl4!ZT-x9Y#fRgV(J z$S(jJ{1Dix7!?Xl|t1#np`D7VivTYC1_`sCNIdMrN6l8Hx(%sH!na5~5ncXqKS@(ekw zKvC#?y+$fsn$`8=*1W;3(c}_$SowA6GWb|&EE-Su`VOb&9nYPlXZu9e1`rMRE-rc9=15X@HeWPea09Y%Trmi_LFK;_1W&}f=i4Ns%t6J zRnd8sKm;%*g23onUzt@vyl1AuFbgYkzefHjy`05acW-*1;#=_hd6^$Gf6p1R+d3_N zr;n`l<(An4C7gOyH3+)7qUQ$=VJjIV$eZUkqARa_6J>$4=PQq^hng2FRJ$DJoBwMC zOwfQn7s>UL5Yg(jz)u^=Cn+u4zX@>4uy&yEcwnEDdGg|EcT#rRmp?rULuvk@cWa0l zfwGTAD0aP29~GZjU7JifYPp``=_@_8WLsxO`@SE`S2kW>o|LP*?x7G*z&D3LHdVgg zrt2>bsk1U|k;}gF&c1TRQ3K4`>+Xm*zi5J2x<7~US7<1OM@_9zO!_g}r{QEQ zT6V=of#yivGW|*z$1Zfxr4%-F$|e5NetOb%o?iT8U_hz^0J)jtM4&^S#s$uf5^WaG z2+j?>OQXwix!D%VhZS*M=5mHj1!>7OCH1>c{V4R^ulEA9F__-)GRl?h>LYEzBuJ3^-E(#%r z1R+*25xDm@yf`H{_uaC)TdV0L=!wtC$;^wyi;$dk)hsP6U%qIa z(~IXz-_y&c|MPI9q#AixDo<2%;PSj);8|rAa<+fPoyOC5lQ~JDniqHFs%sg#Bhv(5 zo|ex$3H6;x(C|`?5RTw^&PYD>C1H2u*FN|r&Vmv?f znN2nHvbhpzCn|N};)ZdU!!#e>A&vOA2Sb6!1Q#IcmW+AkS zVpQ)3Wyje$^Z)y^)#1?~`rN>dwSmuE>jT62U6>{*z&qFnNov+aO*|=3Fv`v~au>R$ z4Z)FR5A^5Sli#dZRMkX2qM1I?j0dq7cJ+rk%%oTkua)1^N=VZ4C&MX+D&@bl`cDvk zA3WQz-RF|cI4wSO|5o=`U6;-Y0b3Q&#-*`auVdZrZfwr|=dl!3;Z3ss%*9i9|4%{g zA4b41L(=(91<}W}wBw35){_vNXWY@Ayrj9E=ad_I@T<&h8uFGJ0@M8P;mHC&hgAjI z?_(N}H+9X;$&6=c|GS<=jElm{$VdwJ2<|LM%!@#zu=D#ufe#O6ZeFF6_9_LM;c`FZ ze7AOdUeY0h9H@?b=LkG3otojgy%_rjMIXrg6huXpK+G{LA?Ur5nkuf}^)B@KV_2tL zcp$-rJq_+Wi=WuC@9y%`vCHe#Th<8N*yISMAU^bumGj$>FV@|`Z9I|gdlRmE09Oa_ zww;_!kq{f&qI&cKy;>>8O(oH_jAe<(nUI4Dd#b;21MRvz67yH&v>`8DHg@P^Q-a2XYv<~ z#3{*gs^5dzra@nKn!jW8X-)gDb8B*GJbVPL%TQWGDm_Cq8|)91rA|owl9wDrNtGaC zGu&4ZUBU2hk>9Fn_p`-G5gK@_((6U-Rch1}q4I+|k@7VzVyYGJ+);1X)?(*zwAM(k z#M0svW!~h&k?c)k))p!oO}-1du_$N05C<(MovXcvgpCUQtcaV(-s)7=#5+?FazT>| z^(=C`*|lZvEHwfeVax`Tgi5?6WHdrj_pKnjJQO_^-CCU=#s<+Zcz58i8>-bk8T&;D>?8DRy(aL0WJOt% zD`SN=%mt5!$<&%xz@mjQJ2ko(f0QMBdhk16YpLPSCq>NkN7tv1Ay-;SDLn(GQ0&=_Lo^5P3DHK>?*l07c&Z*7tg8p+gs7%&%nY8gh#;!l;1@N-@J7rN5CZYRd_ZWr)$;!`NUM< znv6%+l^*9?a?gHGZMqNV=CtGEz^=TAGDC3ts8r=L(SN;~k|;xa z;Gu)HRc6hHH&w?9i{(Cd>?NYlfza#|sT?5UmtoFc$oB<9=>g4W1tt-inhz{hFn6FYe0r z`^vRA0;4I>rZW82L~l0@%>T18NZm<(gytT?JCP4x_={o~#G^z$vQ>N8rT3Cl2+-D- zpaIo2chB$jqY0ECzdu6039bR^Hs@&^KNBzv~~Z$M`?S6=#lIL8;TZhDy9S5*_Z z@|G@?Z0lX=u#UrQG0E~Q1yuKpOytMFT~h;oonHn+fC2StvzQ)U$(d}QBuFERY#Zad zFez(|IuaOu+@k@vF;JYkp8e~`xoVG+r_VGakY>ayeoPTQ_VH+Igm(7l9{~rM8doQybl7mL za7J=hUrx7Xp;TW0V|IW{O)zWCA7}QSKrl5mlMOJFGR+uUE?D#pcpE7;be=IMKTl)m z$A-vovApK8Iro+)liqDVW4T2l(LE68Zh4t5@6+xb!4}G1b=hGNk((zvi`8+_4N8wt zB*R@tmNq_@I0ft%-<^&w#nO~?zvDDJK#(S1C3Wc3NU&zf3;S%FDD=-?^dN)UdoZq6 zc8l_gj{_nMzf0)-hi{KE2}fIX^wVSRH|#fGd)idPy1q>SkV3Ki|#YS6%LA zzq|h=0}vQ-ww39$Ha@53UKT_@p05&nTM6*|`>o_K_0qtAtjsz!1kT#D9GdHYKLqqj zMVW}}sGk-+Ud_Ody~)$-yvW5D**xD26JaTTgFL3@a^mMQx=f+M&&rgGcyD#K0xl(+ zdToz|(UfUBh>k1ZNRBDyG>AEL-0=IVU%TI|8*$lr5)%GY6wkxL?98YLnAy9P8ej{Q6Cf2^Av_4dYmEPWO z0ymGOXQz%pF0*MmX%jBeZdgI$?Eo=9`yApI{NhmdF!NDm?^|r$nm4J_5Dtod3`+5i zwhBXdXvDCbtomvMYRmZ zb?IURvzfU^owM{4UPj>SP&>{lOK1(H7s94*1RM`m6XzFOLvOI!@SP+@zU#m>yY^mg z*-u*fPeqZf<#1UXKDp;D>62J85!WO=ee?lFo<&fj$Z1VP*pS@kD|Qp~see69wfQ-O zI=;`IN!4Jy@P6ffxA95Q?<@%;yV>ay5)+YP1BKZ5cSM=sN}4Y`!!OcI)3^o&h1IYb zp9Nx!aL&guKbJW7Sb1vk4q)s*P_yxIF#q(+8O3;fUtoqeq}!XcA`Sw?faekoBUBdn z>l3^ugbjsnoIi^7e6p=`jA9OABJhAr%T(z8z3fzn?9zK>{Ju}-1AG;Wj3ihj!(qY3thTan*u z@Z-ppz&-rpc-+i@kDj9g`IWXj)zXMp>SCgMZ(l?0ScOdfli@br*?u3Kez#v}Kn2Eg z?tEiwL^|2>GG&*@=TLny&`%aRx-6T1Wn(kGTdz|uBC^Lv0td1YjdX&04%z2OVp|#> z8QjGc>E(C@fuo2+3F!#uvhRPVB`v4#-m7C$4=idUS+}Ma7W+ZHx}&CdMr_`$();&d zFnuPj&MxegusIkfa4=JfB3&E5P(^uCkUk{sNdhn3fJrN6+>J5!`6YV5Bj1u<$kJJT z@pGFV0U!iE$}#qGANm{RdGK?(8a2Sc&w<;4WVS5bkfo`Fd@=1QP;tMAGBBWbIL}I- z-xlPu(+?tsrX8B~`S3I-xvi09gCQ`L(T2q@LdmrIkq8DG zL9xn`cS6#3XRfqBJDrI%E$n_3wP8*hx8k>nE)1RvM4f}!8czflAfsFcGw9}0P;BV` zByXkM=e50lHfM)O3P=70N>0)z+L#O_I!uMIv|Oq=B@ip_<}GI5Db9Tl`Nge6G_a>)AT%v8 zi5>p#ILCr2cY55d8Fq5;Ftu#XKGvxLE^UDN@`SBWEo5tf>T+-MWAo}hhJOJc zVHAR$u&SM2jaUEa)Ix6@qs}Fv=}?LRmNAV;aUy^Tl5j&dwL(WY7m}2KL}@ubYe53F z`*~s~@MA3CB6D$1_xcu}4K#Dy+#v|^y;0Y=;_^MNB3h3iV1<1$7P|oW>h6osz!Jn~ zuLm(ZIX^&i$D;QAr|bT#F<`gtb1&KlSd93>0UTVpjnU%_^)rO-3)+w}yqK;`1#5ka zYWU^BFA(N{;-ej?Fq*jjEF6w%u6T)7?*Y3iGLhjK-?ODkz*E|dvpnPR8K2Crf_J6B zIaM}7h3)xm67JWT3E!Ak0HD7Xj-9C~*g?pYwy>rNjklnFGPFRgeeUifGYk51ozvCIM~Ca)E9Wlia&9)?yP_FB zu~-;mP*+lV1u*Lr9j5x4VUrYz1#DObF{jhVImwaK_aumJ7ZG#*UuflPj|?cJ@*&Kv zisFDZ)_Yytf9s8xcxgZj*6!D`WM?)&io&a*Szp=q2OJG}oL6!(k`2#n5du;!deI>E zymLJz!QHRR6Fy)~dzak9=5k?|?RkN^7gtFURdnb_sTxRlq0hathMJn#p3AQ(8b~aO z-ls!0p}(|%fHMgjYN7oRE*agepOu@B`-=7Rm%h>?R>U`l8~xz& z)phv4U>UaXlC3lTFYEo6dMXDT2#gD{gAwCt07yxJKKRdrg&H;>bC)t~j`lb5QDhd+ z!!5@uq|X>{2)lg7=d0>#+5PrQHw9viLklclEeA*7RIMMmq5{d%GmxwZ&D1(Aqo9cj&_kFGrqjHQyHRb)!mzrF}t>FmwTqePa=@;o0y+Ij{SjM zO!rYRgZ9z9PPM)wvBEG;y__8&Ytmzr+a_r_Zp}cKVO@g9-w6fMS`x_rwiW^ZnDFakzB;N-)+>s>b|XEngWQd<8F8x=}{o~2|c9W?zQRPkaG_E z-Z5Ab8?sDBW+okr~bDQH!2lV09?d#k^Lzfh#|Rlktys=9l1x&GO8H+k5@jl2$4U<2nIBMcKAum*=T@?Xs5y3x zj>B^)=1F_?ACm%)Ea-pxS$un^=|jy5W({tXsi`z8-!=ZlZT5r!pV z37=qT(TITi6F3No`&MPq7QjBs@R_Z(AbNWTz`a0l?)B^dDETdqmB8yUocwP;r1|!( zxoFn_9D~^$EaxRoN(mJTCCX|&S34MtzO4-=!hzU@DapQ7UvwX40Y{vd4cvt(S-+ls zB`B63_`??6UJg^eL97r8dG=L>V7(@q+@%_$1j>;I3Mvl-A$2rXO|pz*7UL%OP-!M2 z9fecNIzDMp?xchcnU&Kc@7xXfS$6y1(&Bd8s-VC_8?VA+MB9C(FCqEy`H7j@t=i;3 zJf{?hcwpO0x4P`U#HYr$9jZpF>s%A1uVfD#((~2GqJ;Ae*pY}sg`?z&g(s7CJtV?6 zkmh98Tj}`k7^=W}KIRQ8knVRuIre@nb!9e~SLCo(rm59M0aFI8K_B6Fy4)LEE zgtie2?U^Gp{oliMGPBpSur)FJ6_thKHH3PShIhzs9#7|EhnbO--tCAys=N6+2nUzP zan>}tv;KO%zm%}O@j@V++-&#?o$tkXYWt}|mowr)dVRswM95LzR-h+ValwWS5;R3I z<@B{Fzu2I08*d>R4o>>Jk3}{Z5S?pu2(}~FLC)*n@$%>nxFG>BTfdxjfRKa zZaT*zW`qGvjO$O@(>Y2LgWSCktyD-5KM#&ajKbk#hRc0zs4`%;FN*k&W=KM6_G+vc z(qluo9RNGxHTut(FZ7#M@IW+3V7E|I8LbWVoM$}yd5&i70k_Ff1$(g$KSsUMbJahd zD#{X}0Uciu>`B=EzU;KLwI-@L(*AZD=3rq6t-xwLDBMDsHL{$_vPdsB!m93XK9J0* z3!a`3pn(d=4D;7?d7;zwz-E_93wlZfVzF!-e+E@e>{;bSlZs=5LOq9CegD#|y?m9( z<*wp#wefdI!6*g+q$~)a%V>O8^e6V^znDq;4Q%CwpsppiS5YCcrr@HmcHz6?#@vTV{U zNQaiZpOrhsLmikcY=`A{MG24-=v%sIod7Oc^u-Gc$>aRy=iHq*XMTA)TP!S_H^51| z2mq?EKth|5(+y&*kt=RO!t>bMT!5PxG_ymN6(F-8?|AfO{oc~DL~M>YO%H4E>S72-03Me~@W`h>%s47Gv&->BhWG|rPt|m3U?d+p5DqJC&8|J*C$#+w zhsEC>q+{2FMVA9m2Vn=c{&?e?%;XOUVK%<-JXZw&Wn?eME{ck`5BgtQ4d7p2EUjBR zcQE}DF-dVzywqNZ4vBbWoV{CNEQzndr-78Vvs0y2BFlD@wz3>JGH zbR`w(Q(Mf27ooS6bMb5ysG8P-`6K|$vUFrXEc(^nVe_MxTu+J5wI3Wean-Q{Zj1w@ zzI82o;W^-jS^iCYmz8-&?G^7;h-V9#&+xN|emy+PW0|fzfSH+2fska#vM<0xwv?B2wOMGLcp)Q#bNuhX6 z<^&O>5POJqvcaCwH$MH{ObFG_7SUWD^1c1^%l%RCqA z*NP*Qa_|DGCJL$7z{8Yj&Ishs=b|v9I8}`s!040>mfqWDi`vubRpFx>^B-M1TlVQmRKH!Vs8hc?HSakvyM?DzxEk07phVSyv*C0gi-a z_=}TV{@r*w2f{APzgah1lgHsxtfuyZnQvYwts@6YQf+7fKK;CA-8{+fz&lci{NvXX zB%jqDJ?B|#x~GMM3%pLpi&{3=%qxI5-DjL!Eu`aC){aizndz@!D|Uz%jrNxF#N*?r z@~E1{mkF`|P#4a&&(A4F?1V^y42tZDgWl&KVq_8p}x9mr?*?|6M8w_;az2`AC_UgsA)GoVm1k&gQ)VJi zyFT@?$4Xb+oNxP1!@iRWC*Ld{eQsnE{9|L)#s=w24tu;xS0@e&XNjF_@7)B3!AI)= z+SUf6H?W^)A>7`u5UB_Hh@(S?U$69cu~lOf)OssaZ`Yh>s5xKG4`3Q^Q?^E1kjuW0 zx4LEl=Qb7(9CHXdDCa+N$|avMiM@5*MRM;ZEv_mY(^^le+IQDJFD<+?aiwzPz5gpW zk0@CmwU_4!Vi2|@*L;Pmbso`hFIkAM(t1JfGJ@WTRGUtbEI@3D)Vv+;VUaHi4&e7Z zF%(e<9I~2HY2W&L5UpXijQ5@rXD?zo8b5ktZU55377iXEK57H|eK0+Onh|5=y3i#%^yv{ryGj|A;5wnWy7libIX26APf8rr5a7@@qOsuKzSzCSQ}Lkhz6ZtfnX&Un#~tSTj{fgm*J+wW64|Kw(fyOPr@N(pac%(B35vz`a^)bG+O zQ03Ua%2!VLK`@|;wq}c*=%8)m+y?LK3sn7&6ztCQ%nv^=5I0E!pmwNEZ9qhLCclSZ z2ZD|R$f|2Oe#W1wPZFfY7IBD>)bjUCcn`Y3W^Ge-NA*acH`J?lMAe3$!x9j7ZjLqR9bXe{e} zI1X`@a9CLLk9VGpMGo{K8wUt(?*uSngQ%v7%cnz3`PWgA!UaWc0gc=0nNnqryLfC3 zL=<7Q4f#iEuGYEmjzK8B2v~Hj!CdreW^)B_fc!eK>hd|0WW(9L7iY4RWF}L-9|JnR ziM77Yf~MhX&C!MqkvzjY%9A!uf8~C#c9d|jw2Z!NgY5m5^Eh}UbUo)|EKl(Vhtasb zM<(%_=tRik;u2Kslni4KhISO!XP@qLa!A?qQ_I{KVO1-}BFVM7)SIKY+>y7R z7Y3ust)hSP1;2Eayb`U%7Gv>$`*RHon4cRNwo9~w*K4IIG$U~CHske$%k#XPoe~U^ zf-nn>5lhgtRh{G4bZ8(->k3}O4o836n>1gO%%b;n-{@uB9n6e-H)7J}^#-(J$dt^Y zH62ARjd8QS=n!9YVkrzI2a#GeiRGeRhQy=KHGpCTY7_EBz6k4E_Ad+Y$qp_z&Fv4$ zM>>^qaOV*SrS7*jvZ!CDn2fxk;oK~(ZdvvuY2>x(O&T>U5yI%AR6#BrY1{~sHLzXN zy|PPw$If~OoF}h*YU6Js{v9GNl896)yf+Z!nT@K-TJ`IJZt&ACk8P7O3W=Z0Kjw>_f54VWY0vkjFG{pb@1^Q*o#L$tEbqvj+<_kw zq>F}V3K}@Wk&9R29F;R`N&i4u+xX(-}DO9Wc9ScyRh&%FT-;%H_w8DZo zbmLWt2098bit`!EmCaw;r&k(3m>B{ZZCO5FE`PGh|7l#$Rl^&jWf3t{bEx+Q*XQT= zNFc5w8Cl7Tc_c=tOQ}*H%8BBSK?)S3mkMZgGrS-vA_|bS8pucQ! z?KVx}_};geS;wx4i<39mr)g{CZ;~Y*)1a=I3e=i3_#^Zn3sFdd*lUyeCGnjl{VZa> zMg!*e&R$Sd@CD7BHmpj%`+PD7Bo)fAzPaoPFDvt*e59`Txco8EKR$HX%F4iSyPd_i zsP=o8RflcZy?r#DEOfokX=oXL6&f|u-F?xk>Jw{kTpE#nwfb2~RRWpQ?OX@R| zgrN8U-40?Dalo{lzff&mBr}OKKkY|nz0nlb-eX=ExfKDo{>XpbLs>QAVR_{ehDAKN zGzv`T0P=*z?9Tgm{d7`r`Ot9RnnIJ9@Z>t_U9gzNgGnBXs|?|A(Al6F;uea)^VGq< z2m4x*CPSXYr#9E%amrKMO4aE|4P3;5WTR%~IUQ46<1H`oQ%?*3%fIE|!ohAs9?!Zj ztA)GXJnpqYnrBz%50Vvxt?^(r2{JFV&FY?i?qJVNQW5sS4x+;_ZWC;J)8MIxY(@>x zZtn{Iyr8SV!CXFGhWwQfxzvr<*Un)rv^#$$jS|C!?uDfHW)e;<75~Sr)ZmWY*I+Mhwnghbax z5<>q*+4)}@KbRUHN?}YO7r(H3pqKo5ZjLpm=Vq2;xb}yxZ^yY3(HaQKR}mV#n1J*F zTXJEx*i;{=sX~!#L~yb3V*FO4bi)1##RQ@}uo8mpq4rFPja_W(zdlRG#9UQ)5at=O z8iHUrvnFnGG3L8VX>f;96FtyHt#W)C@vqYx_***p>kH!t`0XV6PKBy!5`D(?w`(GW zZMU}nO)b6J+FalrIHNHG-^C54H?Ob>b*++kin-@2j^N$kh3H)sj_s!>-%QqHW4j|? zH$qGsqF3c7{|5xM??G1Z7Q|p`YL1;ptr^_95Q;7_xyLum$NDGNY%0CkYwqOV`KS{d zpAzdPpjKGz75m7_0yKGX;~II-o;H5Ktb8fHvtWxOPaItq;*9a;w+L=>nTo0A(N_GA zck~yy8K-UprHdRuG&I@eWA&=?6D=-THm}Wvboo=v(s&VV$He2O1Df9?tzf zy8P}Tbv{cjZH|j8kRZfSaHnD_Q2hI(w*sBwt*ck&meXH^<+Wk4W;G(Wctt7oZ`VbW zX<^oR=>H8}4F8c{7ie~v<~83LbEEdf^WyXVpxDvj1k?$7&jg2Q;~g#|Jl`v(PUh?- zC8Bh5pWqFT6)Gh*ZV#r-a~CLy2m5=lmxYA~Pixk@nj;IpnF-?YXGR1-xbWB@xdw)% zX9_mY8Cpx)=bcFGw-gY9^CX@P;vx=4xz3TOa&hmgs}=BYwJN$y9gXw3R5qw>6NCA6 zv&EQY41Sn-P|IgwVUMz~vf7Jct_45EAU+%rPa_%f^*nRP8yJ1}Ag#XObz5#yTqT-7 zk$l@%KqLhBzaq8XS1;%&h>X)5BDED|e-&@c7ehM2?Ix7-34&MI&{wIDq*>_Ei1~uq%c@ma$eUVYQz9p@J@IX%gHy&VT z(X8z9sL&+sfDHi5h~4frn#7D)-+p=&aA|8KD3e!ZyX1A;j$yD8(Xgg3uk)1(a6Ad=BZ+ zdhU#q23*QOhiV@JDy5L?XtlWUBZu}gqIEkIpEYHy0$^-XDKcr4RCTFzg10dBH{ZYLzG4OvYrnd8AKCgI zg2K4k7_#8d(VY-*NzudpSBH?ZeBj?z6?x*%wyHmba>f?9Qx&>4pXfAVmASi+@D`_P zmStZZrfL#+|5P{~i3)=P`*H;6RM7g5W3X#tA zj)7Fk(|3?JSSDMDG`gO)f|CW=6;qkB3oRnG6ha1M-R}_~5HM22K zz8+SK&_D()K_RRZ^1=7_*Kf-<`S2jR6S?yj#fsU{eM(=Lg?&AYru{jzx0Hs75UDI& z&fN*xsVm8GXoq5rHK&9(=hb?JlS}W{0KIw98ZV&Vmf5-J*tvt)o4Yt#=hIDs<~cdx zts_U!(uV*%GIf~j>5)AeJFq49q{dcTz8J61sxCT2iRf~fIk#&G14v*3n6@(?A>P(- z#W;?Yyf-Xv&`z(VlBE}lc0VSR4C5ghv-Vw{z{V&sd?^3g&Y##ykfs2sisOf$fQQ$q zN8s%P>YtyIs}|y_UKLj}v!lcz5TIEyikUy9X+BosEt7ZtF#F1o{0Ka7UP2Cn$K8Ya z2>}V5V^^C!(LQ#raUVYM{

dx>fsy1w2m1MJENxchcrcpY)!GTZiV`_E(>89lN(rV7)l9ec1a2V4SN8pl1N zHqgeITWJ!wMnH7+U?9jXz@lcrqk^ZxhU}ls{+P2}oU*V9(&}>1V-}+UtZl5HUgWt6 z!4!h9W>Nh$n{cfeTKkg_1c5h%rHTYr@GzYT82L@ZJySG)f_PUtHtOoBlP1r^KXJ5v zI77D$%FPqsy4aGkYGb*A)hdu`h-wSbI89$u@IbnXgyhh z|D7}G)%OAO!vev;;x=(blj2$IR;L+uRZfE|ZV6>B@jA*PR?y>RFX;_6OzUJ+SJR9c zEP}BBxbdmNEcWw`0pP|lC{Sc1@?HEO@O6KavzLJi8@MsFtcvmbv z=xWaLrnkcDbX%iT1WLGz|4`NG-W5lNgsXA)(3Zu@(ooV^3_0-4&qtY^S#WW@_@llU zVwv_Jfu-|{x^3tqoQ`Tdjy*UkeaJi%2_LG`Rx_pdYOtA3{G8YS)9o-ji{?yw5Mhxx z%TQz`_%e4M{J+7c?=Hu9W_(eZpmWgKZyK@>zlSFTlAc@zhA??PEPdsX{$gpr7O9qgT@J)r6Dq(z7zj|T5PF?90^u0mj^C4&@S>(7S zXO9cebqt7CxU7Juw6DJKe1Q)p5pci>n=Q|$=paG_Dn{SU>d9@xsyqvb@@Gy5+&ZJB z5~>$mK3c#nb9;k+E=}5jZzh!^&4l=ePCJ}=zTC!D-=zYB``BMxurF-iCGxI|-*#gW zKUQFH*Q32V%w#wu&(rcopOY{&AZ2o4=ClP}D~)T`;FzJ8J5z5X7BaXcBvxfb>6}hI z?8iCn-#?8Y%O`-f*&GuO8$mM!yoj_5?pa%^+C`~6L=HB@R-;_Y&U#h#>eO@*xZLJG zR+N8woXB2I0~^txQrmKV0R-SpuYuy%)Iqtsli=9?U+Q}7FX)RBJ4nY?^e&TF{G-0x`;h&}WDAbKLt`A9#SzZ{d9i4_(5jKYYtkNne(H0X^WZ(|XA8hJ zL#~#DIy=;HiKtY+0lbE8?p_bfTrD;*^8$D*uYhl>6qQNt2XdI5I_$8Zi*R@lN;d}7 zz6-b6dR7g!eLhvu)jo)DGse@z%YKxNQn9Q0-&jd_Z-OPZPZb9@`c=$gO9-KiP~5Z) zu~@lq5|=O_ti&GiyhJLJA&2RlEgG-)+Bk`U<6lp0H?G_E3~jF@ezo)eFye6*^d~si zY#t1JaVJ6W2Iw&9${WaNrLh8ajGryYhCw5kv=o2Swy-nVh2b$oL+P;;({TNfPIXr# z9Hci3`h%WON|3eL$EU==XC5Sd%@CEiaqwT3RzEj|7{0hkvDe}AX(=%?iw9+oG|rak zE_knjVse+))d{8k2xG5Es?L0W&_o`?{ z`^jvBe3T`Oi#~fw;0CM%mRw?y?obKoSh_m}q?^6#=X>veaevy^{xI`C^UO16 zo|!r4;27hOPtI0xDJpYVO8ko+{@xp_JT>^0dN3IkhRcHDM}<}Vny&-U*m)_^8hY4? zA8<8!hXY7GIW4c2Lo-%RUPd5+nQRo7;3=%`kFC`TPD7#lCjeXT@78ZaYqja_0{O@- zm*YbMa6=;c>a(&vw{H`MLJSGeygsN2*&1}0uR|GAPsThR#k~l|2Vuz2)slP;UxoCx zP8TuC@}}qDA}9*d6M4Y&3x~ugUqR7Au(;Hav;!%H-FdU1)`2I<4z@x$uRfE&fcb0) zrn~$09m>}21RE)aiM?nF{np`2w0+C)% zeHOha{HG_%1xIthJC_xf5a(4g?WYDoDif(F{ra*ww1+>N5|ZCSslfN8HH9aM8t>F# zg(oDc^Lh(If_nc1+1yWUOD3%f?C;HZfz;)^k(1mUhcA|EgD#xbeztTE=*px9SA~cC z5F-{}qV>5>@<4FqgKg~oWTVz!%XiM%D*P;e zmze((M@nEGk}>wZnYUUQURiFb2T|g^q!RlX10Ec$wTN@B(9Y*;=d&`Y2t8%I%7>0# zqvbOM_TQezZeg7#{vA^Ea3>G4RxuE-M*bLAl-I;itHh2#MWh**y;^M(E}d z2UgzO&MC#LdQ`9aa(uUX`?vQnwoE!$K^XX%jzGB}M-{C)Z$m zthMl%Q13wYyByoVY(j2gceG)<@DjaL*QCGK&q9#bT^H$_Ji$6oTA~jCF}7#r{7eKB z+qkLTr3ZD2{|Sf0y0GspC%&O>o&XNaJ#_VkjOb4N)ht8m?)h(kli8}jzs3N__u#-z z-uo4F6rNa1mY;Ezk9%V{{^S=me|gpps*#5t>Z(&`tNXe|u$`RVRV%L8hwxgZ+X^6} zw_*9;<(kIaW|;qaCvcZ-{hOo&*@GuaNDM&TGb0ZGlm#j+yQBT>0fz*3VgI?*H#ztt zX}l{()`Ej-`jYTeTlhw!@;#({L%|sTf-$lgTknC<<&Yp03l=?loj@zMRT!idHi#rN z2=_sv%!{pbK`0$p^$RgFZRVG8Oqzkptrs(QOv|P3UUVt4K!n<5xTX@3=Z`kawL4n0 zCIsr-wt2UI0&3$e-T2g#V3*KvNhIozd^PSH+#@5L;V!i16SuYfE|f>s&ugVoV>g

kQ>(tI(=nh*WfXabcoQ5`OWOb(YrGeuEStWicp2rYn^erP{etJ%&qXvY^(Dt%t* zT?qeu5bIVXK&5uwO`Xqqvot+sy{#%xz50tnJTcUlu3Ji7Kl=zewEXRV`VX;&zp?RA zUz@e&s?cFU7huSm4!0iD#aHyU->2jx30@FAugs2$v^Iqd~w0P*iNB+!e13((rWa<;7 zjJAA|oNVkQ40<@Sv9Hf|$Jl>^JYDG4&-+_CzOrASehYFzMQRJ5E!zKRLkJ9(F}&&T z+K&K{WY~X)YZtqPXuo;}zhD|fzWq_B>OYWKWpq=mkCtu388;mtZswswM2gMrC52jN z>V*}WB?*O#B}UqBJvn=lk|$pw0bRqfxX@#|0eluNO26%c%E1?7|^szKEzVH#Y9h`UQ#oy zuJQHlNE#~x34fC)yN@Q4c~EGv_V(F4KR5nENuD0C7lUS=LY9Y27_|Tve(F z;LR9$UgEq-^f|>FYRnK&32s5q{XnkP!1V)juFNb*eee9#t}d)f|DJ?MTl)rGV&GRa zhM1W)Q1*xRpl*=h9*)&M{q?JVPWKm`Ok1+fC^_U0St_eHsAmY7T-~`ArY9q#t!b-u z>1(zgP3+f1#qyZfeaaj=RqL(YzYPFJ|{%?vj2OLph~tFe8eB|FB>ePpx?Mk;pa0dZ0`Ws^=+CK zi?M;HgV-KM7kWA(^@cqWT02{m>exDvTk7@>^$kf~Jn{*d2CbXhVx=bAF6a+V*HYJv zquUn~Q!Wudv3B0qG4eZR+gZpun@y`n&Mt4fS$3LU9 zXX2&~aV5z?tIzi@eI;Ez<=+dvu2{iGCf7CkpwLZhMv)sIk=d6D;dBAk6&9cQi3nL8 z2EY8*^pHWk8us3#B7gDdY_Jn(H$ta39%F6Q6hY2Tj*e_!q9AlIh?@5n^Nm7*K{3dB zaq}^-)-eA9+157ZRP)QMXqqqVBz2_l?U+Z@SK7Hx90Q9PKQ58?jS;<|0$%L3&T)pk zSKv@qHW2F2=^8(VCfALaKOiC$#UvxmPc4n)rjujjfhgv%4Q9DpG@JVl(*1vt=JhZ^VftX&{QXK@g-1Za#t}Ru3}W_oU?! z(d11to@}t4i#Vx#mGHcWr*r9LAOBRVz+8}|)_N{&LlaA1E#F_&%W{>Zzc_5QMb0^8I|mJg0tRq8s1 z!CE~loTeWUUH;D6j{?DW8=h&Db#K{7QU!`tb#3ibV~&Ah66Bk15&*!Bu4(bkR6|By zQoh}$&nLkKQs2<@@T%5VU5^ScLyU=EhJe)GIsPXfE?XL_~&%T#BPM>cuuJ2eR z9f`podRkH*hSelk29o|@R&-+n0Nu|R+CU3>cJGBNj;Yz+r0ChzYPc44)OQ}F#!>z`&)X}0W zjkw|WY*N#KhkagV1kw=Xgj>j_an*gDw@&d>AS!Kisw&6K`su~i{f;mg0GI=-Tp%ny zE`M)>TBW6x&DUx%%qUl@smv%NPq4@3!T+y|(fcLu+csL^p{Yjx!XDX?sgmiWkZ&h> zI+R9&eL}S263$X=NMqQ?U)>r<*=`bemoG9aT9{X`9!IlYJ$@2;+hMYe-GiV*Lvz6v zKDyCe;Am@PVcsaAhZX1Od_&c4w`}TsYMPm=LsOOxzefcCBxHA65m;7J0&$=U^SpA( zoh*%VXjNQ#LRHP(*ar+Wm&8yMVRS8(U;=QnZJ2MFYp@KC5bDQNi1jxit?4TkHjkqP z9zXHpXGp7_O(61^m;DcEF4ufQl`U+H%KJt9P3_HWeEkOOxf*F7);rLjb8-W|g}8rs z5bWF^TXl>PeH(99Z5wIFlYJY7n(90Ub$%DrohL>$sW^^Aea~Iw`oVd*9T+GyMfz%n ztgx)SO1CNK9Zds??Bl#K-JLi(5_tMioA7ofD&ED8+sOGDj>B?#(JB5v%PqQwk7;BN z{Yd;J#NjQwh`V=}9sRr0X#=Re8ge?HZao!A$pXbMOVJe95&>u~k$O+Ft6SBEZUxh+ zSR0Jd*={+y3{^X_(iF|+Tw;D_gsU!-gdG@FNO}ep!7s!~ChqLL$4AVMYsD*eBDGRM zPUvAl?d>l!uHOexLJ-pEc-^nJygSd#{d>Pwa|3NOEtItwvOyalEZIP#yRf5!)`XlM z>*tvDxXb{+xV1;tQ(?9jw4&&_!|&O0buFGEyDis>_kwBfJ8DAq5QL~m3*T0<&i}|Y zdJn{*=wsA=_j?G#ap)4%p6vynT?wKJ)-w3chGp;%w`U^Ch=kGd3ff%VF%{A`POtJT zD_EmF4R3oRcjp5BOyEa85xhpuM8>s2Q0e2+H)C6+?S0`3Tmiu>0yS1oxGrIiLmjnQSu-k8;LKvgeqjPG#de!0YjC=g)lz z7{}8~Qfz=Q^B&&aB6l^c%eA!CzFlIep8r>o)wY0hgKnu5k5SXH?nrCbEs+0_mKg~L zLx13fM&~7%Q-C1&yiHxeac00R@iS#(8t@JNLM9CMt!=v2)Q&~Eb4?r0xPuyOqn)R>&|@h8g#)_ zVw^a*EDP5Gm+m%q;=|p5c5L>?DVCe7m{q%qUvbTLUIJQ6XI09N;gza+XR@dih#4!- z5oegG=NY#l_CHjz_977;F?r2qDXmLUT{^8VfC3{$R*@b0RoWw5c>Ri(uKR6W*9`oR zUD(LpKeg{H93JU@xUk~A>`)(;j3YU$nKNaumbIIw#J9c3&cvY;@y#&(XEsL123Hbd zI}Hj=Eti|m7f(wU+N?3=AjJX>jrx3?yxV_Ik03|x)8t8togRf9=hZ8@4;R@&&?!ZM z8vmL$REfH|t)xWR&W5j@h?UJ$G0%)(0ZIkMW^+2gT4eZQ;p1iA3$e3H>uKHM5;x?h z#FZ5z#W{YA@Y`yu7faP?Wqi^RQmxyqMu;N6@$WmC=TGd;O-0k+E<|#S z6=kCQEeWI^#gc@o)8s_5sn)(j$PpsQNnVC9gsi{eXQ-GRpPIoR>7lTM$T;we7K|x% z>xIfyuSzxvXlRQE;~*MmNH-dP074i-oh0ak1&84Yu!V9M<%K-z6Z;vsrs%r1{gdx` z-mXwY8WlOX?m*ybTj)tV5)p%k*G<&@%{e%dw&TL=JnZlOTt$gKWtVy$y?yHBFnj5& zPiVhWuEzCPAAj%!?o)2EYRZu)rbtQ%Ckg=h+67-dK~46*noJd6fgaP~L=iJz52w5u zva2RKRn6E97^}cfP?Zhy^SnD7lX^%%Hvz;G&KP-dtlj`mxX17uVw5dK8t6(Js5V>mC7ds1-G^Oi9qIS z5?d^_f>LO8;2QSI_R4h%WQQw*(-ViYojifth$p-r1U4$>8usQ!bwD1i@8$fCwY?5g zDo28V8Jy9)%6%cC=UF{owi;Y-PyM#M>aNwD#RqHb2lH6t$Cc+78+-tO%Ar-g zE8r7tw)U)XQ?wgME{m395LlDf&llPayB?n#c08z4t_c-;E! zMF0H5`QJI3ysTVHxDEGxO`Cd%CaFBL9fp2dJI?P7CtKoX-y?KFUgB5vwtKB^)E}vQjFTw^dh)kf0gMt z`fQM_qCH|aH92K9AgN_7_~F^{nRhtSk)pdF&<2K|JT3(bXnL>qUw=4FY;e^??_gpf zDRp>zO3K6`ZJY1o!(SA-SUM%G8p+n>@~&&WN3yj$xBkcpB?766A5cBA@0ZEFb-TdF z>O${7R5!+BRt6?Om@CS)WX~g}@KW+FL3xS}YHXjYU&U8{O52D3S$zE@$B%?t*!po% zRT#?&7O<=TpyV#fa-Nr?=4zMxDOQLH@`uNkjPlp%_c3YZLc7&DrB$G)LPEP=^6DOp z^miY+NdVFV?83={bakgl79RiR+zlLQUVIlu;Zsjs=+|Is$h;Zjfxa8^)3M66-WGcA z2vqD-QI@4F2)tLVmaRh&J;&=-|LNcN?KZyP^kN17u;l}v<=emb!0eaiX7<`!Y|-?J zuK6}L{6%LGxQ?I2t2;eb7{M)0u|}fAlUYi)6ZHQCbYQsg&zSjA`vchGRT+4s&miUQ z7yd)c=ZNwM?cUnRE;lYQIR|?gNoj&KNP3P}TISj8g5uMD3L&Bp@G_(j{gF$2fClp- zuBmGCAaU&6*33aeUek$*SASHO(l?Fa-CKqcyYE{Q`5Ff8=GA^TE3f=E{@{q1AH6Xi z7ep<$js-X|qDz1`4ghwmKbv%KPO>2P*E0M;LI~^`J}+Q;heGi@n$!5Mq4qcj_SFrmkGU? z_sCbTBWncF7MDDdNv&FXTecgaUp zaqVI(+6?u>T@pLIyRA5~3`R&O`3ms{HYRVMJbY^dutZ7@t|xK^{L7 zl$}>{mI#jOxW1thJ0GuHX!Z+n`rsa}0_5vHtUV6(k{LJ0#9l@RPqGCS`|c@YE`iUXA1RuR4+?jvpjGfR4W8(yxwlJLa^4AAJW-G7(~md z3bO_NI)IPZ20vaCJ{7)1@+wC+;8t$&F83N9UT`);78qrSyJxndN?F(q9`#nY6f?OC*VcpyWylTB?E(gD_W;t&Zj2>$hmA1pl0v5ne$Y z!#Ikr2G`Z%bEq)Gb|cBZ45`tH1*E@M?yXTH+v;h8nI)E)xX;fv!CtK~vEYqU@Sw58 zh1b{S+tx3Nto>GfbSjqiZIZlF0fb0@6smO&))o7*Dt677>^x((zyaxU)l_y|($f^R zGI#Rf?GLfvA&{5PC1lc(b31Nxl8(CJTd$58^8U#Mc9b>?iI@~&O=V90A(6QuZ#{R8 zz)?rkAU8vv9-uf_%yKATU5vIiB?Wi{>+7qZ&LE>4k3`)TnAaF#70s_~lajz!0C1b& zr^oF;n6=scsGi@9;<>ce$coO4!38sSVFRf6Fn0K)wxtmhSQj6&Ug^cl>8vl0eYose z_#Hd>VV}32o4f$%dF1_lg8PkJN?ELCf(PPIVLBuW0_5<~T;+wvDyY|yU0F-owhStO zbzAwtDU-3GJnwTnvl*q0C(2)b(DBAKLnoVG`IIm_bvZ;1a?*X* zSxIfrP*A8rzt>6>H=WlEl|k21gkJro(mu&nSB=`ZmqsD$#m3{}n%L6&eFm|#1ov5F zuB*ZGbx1w#slYM2@w6G>6J0<&eqKef1FgKcnqn5x7h?yCy{Jh~?BJz^=k4nq<;B}r zd8b%ek!YvyM^)>S)oXktdNf;cPX7d=WvMjtO<-JbmOmS*1RKkUGdVT$Navkpcj|B` ze~kozLFp=4Oug!D9+#9i?c%rYIqCh#9loEN_TfU@LUs+F-ud~V)A?d*l;3Hjr>c2A ze{N>mg6lKyM>pB-g=Cw?mnfY2#R_RHbY#+!b`=r_$@za=x%V{JAa_$5_Q0^Lu_>EU zExP%@pS>@AHZcUa_uO5D^9zzfBCybCIPdMs-jvWy8Q}7#Zfl>Bws?r~P~`cayeis~ zr~n9G*?Jd5VN-g0#*fha-G;c^MgJBuub?1?#N@kIrIp(;75GLSvo(Zg)zwgz_48O? z@4S9a#`dn-L!K{HZ1VWkYxYuGv(({D%!>-WrLdT*Wj0DWhbpgYGUwm(Gn19sk~O3Q z3MAq$6$X2w>bLOEzjX!6-Pd-uVN+#y11?a4UFHoliJEW45tO2uiy)`P?|im8r3@96 z$|+^zh4sv-@{(4ptUHh?1~b%8u_);V+^+|z6pCM?d{DF))0lm;+JH`qx;^kdH}p$m zmYSy^B>Hv3VzW!Hf?e(R>VapeXBLuUnw8IJFwAg&LR1SFDZkj2;=MjPyK7*rns?*dd989%0?)HO?c9TPTJNv}=lhPpM}cX+PgOuY zxv}A~hKHcVHk+V>+rZQ7UbYj^;n?E0d(Kbyv}j}M%vhh;JexLFyt(QIN-amu=1(eX zy@mSvI)+YUWj>^4=FF86*Yp!llq#nzTXL!B%Y(*v6L^VXr7Bz725O(_@M~ ziofMpdSL}zg{y7jg%?4W_@p#g?#_r$b(1OulRX<5#g*U5eNNUG3cRj&lIKmj`W2a) zUS5_O|Fu86DKD^d`A#Wng>?~F=IY{yT$}3MM?qhXX{p) znX>t@X(sge#R@na6>10NkQp=dtu}U@DTUz>rSxbS{_Mb7`gZnywHG6zxmKz{iDh}7 z_h}H`%yaIq+AsD$VXz3$HnGBNYl~|shAkS(23beg!$8KBOIGV)2Q4>rB;^i~b)Uu+NJ{jYbPqBS=NmtMFgd6pR?j>QT0C3RkW@nd(|=9i1GNAQ?Fimx!w<>9{5tsvi1bG z_DJ*SEI`t-_6=O@@b{3#>t|T3B+JW1kHC%V+jgH0G0S9UWg}tE`LEiSRLMCk-OE+G zS5Zj>3vl1YTJL3}(Wrno0_PHf18>`FkKjFSR&;ZT_V3gzEGGC9Zullt8AJ6(8-6c0 z3i&6I%d>LWI~IZBEfKN{=fuU*ndzF_1SKpSKu1cX>ZP5#Lx<<867JEO0PYAc=QyZy z8)hG_itWm4R`~7VQ34G*Ia-eyOyf2miJR@CZag!HMSqY>)5(Us^SDfDN0pac=_TYA zC0W}^kIWV0!K7)(zIj`5sQ02tBXj#Q*WYp?c61A#8U802xa|is+6Xjj3%HXCScvcS z7i1?VpRRK84>q70=e~qln?}%V??t?`;@aZVrf#8B>3RFpR|fL} zJS}gr+*?sw+BaaVEbAW;XXl_OM_MQ9&7bv&jtwUh^EF}f366C`M}6$q(^gKx#u^u& z!;SIk9-QhhQr+V0`aa%o<>BQ$3#LDbqC|Vc*MI!h^s|4BB^y_~7!x=ynrR{ZHY{A` z1}Y3n!)372w~eCq+uwh%2j8@_H|9zNqoR%Pwr@rc8B|#;9rh`TL%cGeMe|GL)1!_? z0?dwY&Z-o|phhxFj&#$Y6I(9+&*;V<+H5t%FjU4geUloThC@o1ti7tv^~=~IBzz&) zS$uqJ^_n1-L%QE#uXW?BKF|?hYPYc-g-7ZexvQ%gGOX=k7Tlisa#VaeD#Tq*e0QHy zRZ>N8n=mYPPQo#DS6?0CmHWxRN{|vINio&%x2EdSQd*5cX6^Lid#_R5@`WGvzyJ}B z=Ss_~(86!I*_BuP1Lr?E*8;1n_i)u#Xul%VD{rxl4cz4o_888z zv!^&RA89?pn}4zBl%G4WB{jj3!DjVY&wTeSSqBGs%pjvP$J%IZZbwz^O~yhMPOjmC z`2{HlmG;piTzk_ASq{KO@%G2ua@L<_TOpx_z|h*;`1R4Ni!G6CUC%{PkxB47I-RMR zyWuOhd+-QlgF!jyGXGtTH>A4uT(gA-73wBNpi`QRtflKUhC+mvpY!@tk6|0@GJkBC5#y=OYy2iol8asTj& z*mb#@b8M4i-pqiDJXQ%LPW?l68VgxmKMY}P%hk|#-mnWy@uRUHiED0x*VVUw+l0YZ z+F`@#o?C*j%)@rsR~rkTCv569}npc z-t|Ck5L;RRzN6`8W4C*woG52_ThOq)wiBD*d!5;XddHXp1y{}rN3l^S{^es~{Xb12 zg>1ab)6ZZK^D&VsoXRtEy7M$gx!I%if<{e;CJ~Rp@RmC88SlW6V%lloAR}A&`%ACR zR?B1#_<&|V&xPNwrJpXPlwU<&ur)CWt}4IVYX5$3S6A6muiV&`yVr)NH`@c|V1N(k zRmB}*CI*zuP{6Wj;A*f?f_bGM<-|J4|NyG!riNr&sN$8V4HZ6}G{c z?-r)fL(nh*?^&Ghcf0;Lk_aR`RjWd=Iv<-@PaWT@ek3Cw%1SPecHAJ7s>;-)K7a_y zGrC4x&YkD1`F^%DhUW4sk};>Id70VAPG}6+a=~wuat%gyiPeuj@mI%Hk@#m{Q*DuG zmqrOx40~Fk8mQ%tWDBs?+gl3B4wlEG@2iY}hnf{f7yhV6a$<1V$A0%s;q=^iKkcaE zWmHUA#TENZEmz0z_;=y3wU6oJ63z%;@04MIG4_aMcKv8Xh<7-Duy?leLP5vwm;|&q6_TUAR}&t z^4^w$;Qk@^<>zaQi|M!RU;9#33Nd$8=)pxImSzUF*7rQXQBF{l*)pg1G6%`>E0Rg& zPD*<&P{U9PiATv5kEECio44fh3({NQ~ z^6n+~11^k?2}L9LMJcwa_A&sv!km`Y7>xdIXFdgRzr#^LY6G5+Y&?m*0c}ps2k2Fm zV5sX0(U)&trPe#V{8ovjvoHaV--HL>aLX~ITx8wTIGkLHp?ABZpaF~s?ZuxajHD$f z5o^vFxihp? zg>4qEHqIMN8WoE<`+3-tOmes+xBM9XBTTL$%vvAow;=s%J?xOmz8 zXfny~Z!Pqmk-k(p8oE5X~{t?l!TwlznK``fc6gC**FErB^ZMofGx^MvV@ zTtlf*Y=sW7d0w5f!jxM**{G}Tjwilok!=)xW1P0@l}Cd|3@N|qgBm@LO~z1z-=oCrB`jpvDP4xQ2-|&|dPCkvvNG~f!ImWW!TIX&Megsf*yz5EI!kOn5-b*Vw#jjT=7xMlCOxSsg~nwehyBY*4kF9H3@YL75aRG zanOXoacfMySh8`MN$s07V9xJG(Az6|`Juv3jjC;jG-w0TjF=@a6D?At^uOu8QCliU z))Z9CIwr=`PuPOvMA>)~3^WxAbnUH7;AP*VVd%Gx3_ngS@ zF&91%Ixp}*47wtkCd!rFogXuBJ>W{_AV*#6}Nz;VewD8;>(gYbD0lz zk`QZMn6C@xE$**&tWT4CR26it>5hZDUYWwDD$tKi>8cF(OC_1MZ!<$GRR-q!Wd@71 zIDZFy;w?0*`ooqfp5}(5QKE+@t6x&w3vmciFs+fmU!-5X%)^qj(sKhQ|K_a?mJw!n z3zkw>lKNx$#aU)}`SCl+5yZVh>fMf68gDAEZI3c;7D#1Q9M7vF$9M4u zMPaYs5m>DLuCXrej;6okkgsOnPW$beT8_HkJow8lIh#{;lW0*(9m=%L=G5m%AoPq1 zi%;V??OE8+ezPUZ=}_>tmS6+fMeiaa`a{rSePtm%@JriiXc!tEK$x-e-WhD*K$w?q z-+4znHGG%7FMTCGX;>#TiRK>xeTjL|r^``D?fpSP$nlXVSHOeQap9Klv+H`Bjpug| z&750U`KY$7$I*+cMwrI0^RRkPue`x9B|b>5xipQoovNm}yVNgdAJJYb$~Zx08^Hxx zwn-v84P|sNbe*FxRN<>pt=7Z~V4;~7q-8{)JYH+B$C3s=*GJ@Nr8b>;GLa^sNv$~` z1QVRYXmqUEVh2r*1{_rJ4-f4fODUBS4A`dr7D>;Qn66UJTCR;T3R=)Oi@af*oyQ1XvCr*BW;HzJdype>C`lSv+7 zJyn#!qcjKkg~=?Ql+MIPo)4Px5Ttz`k}m&~1<^dTo?LNDWggX&tjiXH=a5hmE$ZUm z)kAqI$_p9Vdm=#3i?oO*8Z|}VsIz^l&M6|}i|~p~w`nPdkGjNu=@(3*=2EL=%opHe zn0j-PxKJp+Uth8=udJw>BaOC5vhbc)8d!0zCE`d~wbY(A7zb*MUVO?4zz~h{C zrPY+Qz^Bq1V=Qg;4HQLDxId^PGLc=hd(@z>6Uz?J{qk-xrbenAG6f zTA1M<1-V5h&%g4UXPX_*SypV5X)7GWrAU4s@6?q7NhP0fdOK_OAiV!WT?FcfJH5f$ zu;U$FU_U;y9T5-Km&E50mp;4CEG8BcT|Zq@Qc95NpUC|9@e*HXJmtyFZzxKpH0R;< z-5-K375gCj*p;?x&!VTZgj)9vOD=kC1ceE3ReL3rp0sMC8sGkG*ml+Vz|D zCjlIiG@yGCG!N<$ySdKA@!j^Vy05#wjqB4~ePVKJy2Vh*Hokbw%)4~{li6uHvAc2t z1T!pmTzv6yI}OjhU5vYU#;u4D>8v}MrCb7wf~}tnWe%(FFeZapgAb4UO35Fer0!d0 zxdH;t=JWOwE(4y_$E*ifI9dgtK3rS(Khv9)y}O}4evE&xziQ+LoD^b_A}dr7a3kYP t01~vQX#ajMkRc5J|L^}t diff --git a/apps/server/public/manifest.json b/apps/server/public/manifest.json new file mode 100644 index 000000000..949134a10 --- /dev/null +++ b/apps/server/public/manifest.json @@ -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" + } + ] +} diff --git a/apps/server/scripts/cleanup-vercel-build.ts b/apps/server/scripts/cleanup-vercel-build.ts new file mode 100644 index 000000000..ecd6e75fc --- /dev/null +++ b/apps/server/scripts/cleanup-vercel-build.ts @@ -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"`, +) diff --git a/apps/server/scripts/prepare-vercel-build.ts b/apps/server/scripts/prepare-vercel-build.ts new file mode 100644 index 000000000..627236e09 --- /dev/null +++ b/apps/server/scripts/prepare-vercel-build.ts @@ -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 +// 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() diff --git a/apps/server/src/global.d.ts b/apps/server/src/global.d.ts new file mode 100644 index 000000000..404c55f1e --- /dev/null +++ b/apps/server/src/global.d.ts @@ -0,0 +1 @@ +declare const __DEV__: boolean diff --git a/apps/server/src/lib/api-client.ts b/apps/server/src/lib/api-client.ts new file mode 100644 index 000000000..5297b8975 --- /dev/null +++ b/apps/server/src/lib/api-client.ts @@ -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(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, + ) + return parsedCookieMap["authjs.session-token"] +} + +export type ApiClient = ReturnType diff --git a/apps/server/src/lib/dev-vite.ts b/apps/server/src/lib/dev-vite.ts new file mode 100644 index 000000000..53108a603 --- /dev/null +++ b/apps/server/src/lib/dev-vite.ts @@ -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 +} diff --git a/apps/server/src/lib/env.ts b/apps/server/src/lib/env.ts new file mode 100644 index 000000000..2dd9d4a4b --- /dev/null +++ b/apps/server/src/lib/env.ts @@ -0,0 +1 @@ +export const isDev = process.env.NODE_ENV === "development" diff --git a/apps/server/src/lib/load-env.ts b/apps/server/src/lib/load-env.ts new file mode 100644 index 000000000..5d4d10f8f --- /dev/null +++ b/apps/server/src/lib/load-env.ts @@ -0,0 +1,7 @@ +import { resolve } from "node:path" + +import { config } from "dotenv" + +config({ + path: resolve(__dirname, "../../.env"), +}) diff --git a/apps/server/src/lib/og/fonts.ts b/apps/server/src/lib/og/fonts.ts new file mode 100644 index 000000000..356eb94a4 --- /dev/null +++ b/apps/server/src/lib/og/fonts.ts @@ -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 diff --git a/apps/server/src/lib/og/render-to-image.ts b/apps/server/src/lib/og/render-to-image.ts new file mode 100644 index 000000000..8fad6094d --- /dev/null +++ b/apps/server/src/lib/og/render-to-image.ts @@ -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", + } +} diff --git a/apps/server/src/lib/seo.ts b/apps/server/src/lib/seo.ts new file mode 100644 index 000000000..4c70936c2 --- /dev/null +++ b/apps/server/src/lib/seo.ts @@ -0,0 +1,23 @@ +export function buildSeoMetaTags(configs: { + openGraph: { + title: string + description?: string + image?: string | null + } +}) { + const { openGraph } = configs + return [ + ``, + openGraph.description + ? `` + : "", + openGraph.image ? `` : "", + // Twitter + ``, + ``, + openGraph.description + ? `` + : "", + openGraph.image ? `` : "", + ].join("\n") +} diff --git a/apps/server/src/meta-handler.ts b/apps/server/src/meta-handler.ts new file mode 100644 index 000000000..47120ff41 --- /dev/null +++ b/apps/server/src/meta-handler.ts @@ -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 { + 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 +} diff --git a/apps/server/src/router/global.ts b/apps/server/src/router/global.ts new file mode 100644 index 000000000..505ecb092 --- /dev/null +++ b/apps/server/src/router/global.ts @@ -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 + // + // + 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(``, `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(``) + break + } + case "title": { + if (meta.title) { + template = template.replace(``, `${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(``, `Follow`) + } + + template = template.replace(``, allMetaString.join("\n")) + + return template +} diff --git a/apps/server/src/router/og/__base.tsx b/apps/server/src/router/og/__base.tsx new file mode 100644 index 000000000..950162e76 --- /dev/null +++ b/apps/server/src/router/og/__base.tsx @@ -0,0 +1,183 @@ +import * as React from "react" +import uniqolor from "uniqolor" + +export const OGCanvas = ({ children, seed }: { children: React.ReactNode; seed: string }) => { + const [bgAccent, bgAccentLight, bgAccentUltraLight] = getBackgroundGradient(seed) + + return ( +

+
+
+ + Follow +
+ + {children} +
+
+ ) +} +const lerp = (a: number, b: number, t: number) => a + (b - a) * t + +const hexToRgb = (hex: string) => { + const bigint = Number.parseInt(hex.slice(1), 16) + return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255] +} + +const rgbToHex = (r: number, g: number, b: number) => { + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}` +} + +const adjustColorTowardsTarget = (color: string, targetColor: string, factor: number) => { + const [r1, g1, b1] = hexToRgb(color) + const [r2, g2, b2] = hexToRgb(targetColor) + + const r = Math.round(lerp(r1, r2, factor)) + const g = Math.round(lerp(g1, g2, factor)) + const b = Math.round(lerp(b1, b2, factor)) + + return rgbToHex(r, g, b) +} + +export const getBackgroundGradient = (seed?: Nullable) => { + const nextSeed = seed ?? Math.random().toString(36).slice(7) + + const bgAccent = uniqolor(nextSeed, { + saturation: [30, 35], + lightness: [60, 70], + }).color + + const bgAccentLight = uniqolor(nextSeed, { + saturation: [30, 35], + lightness: [80, 90], + }).color + + const bgAccentUltraLight = uniqolor(nextSeed, { + saturation: [30, 35], + lightness: [95, 96], + }).color + + const targetColor = "#FF5C02" + const factor = 0.3 // Adjust this value to control how close the color gets to the target color + + const adjustedAccent = adjustColorTowardsTarget(bgAccent, targetColor, factor) + const adjustedAccentLight = adjustColorTowardsTarget(bgAccentLight, targetColor, factor) + const adjustedAccentUltraLight = adjustColorTowardsTarget(bgAccentUltraLight, targetColor, factor) + + return [adjustedAccent, adjustedAccentLight, adjustedAccentUltraLight] +} + +function FollowIcon() { + return ( + + + + + + + ) +} + +export async function getImageBase64(image: string | null | undefined) { + if (!image) { + return null + } + + const url = new URL(image) + return await fetch(image, { + headers: { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + Referer: url.origin, + }, + }).then(async (res) => { + const isImage = res.headers.get("content-type")?.startsWith("image/") + if (isImage) { + const arrayBuffer = await res.arrayBuffer() + + return `data:${res.headers.get("content-type")};base64,${Buffer.from(arrayBuffer).toString("base64")}` + } + return null + }) +} + +export const OGAvatar: React.FC<{ base64?: Nullable; title: string }> = ({ + base64, + title, +}) => { + const [bgAccent, bgAccentLight, bgAccentUltraLight] = getBackgroundGradient(title) + return ( + <> + {base64 ? ( + + ) : ( +
+ {title?.[0]} +
+ )} + + ) +} diff --git a/apps/server/src/router/og/feed.tsx b/apps/server/src/router/og/feed.tsx new file mode 100644 index 000000000..3bb69b869 --- /dev/null +++ b/apps/server/src/router/og/feed.tsx @@ -0,0 +1,103 @@ +import { getFeedIconSrc } from "@follow/components/utils/icon.js" +import * as React from "react" + +import type { ApiClient } from "~/lib/api-client" +import { renderToImage } from "~/lib/og/render-to-image" + +import { getImageBase64, OGAvatar, OGCanvas } from "./__base" + +export const renderFeedOG = async (apiClient: ApiClient, feedId: string) => { + const feed = await apiClient.feeds.$get({ query: { id: feedId } }).catch(() => null) + + if (!feed?.data.feed) { + throw 404 + } + + const { title, description, image } = feed.data.feed + + const [src] = getFeedIconSrc({ + siteUrl: feed.data.feed.siteUrl!, + proxy: { + width: 256, + height: 256, + }, + fallback: true, + src: image!, + }) + const numberFormatter = new Intl.NumberFormat("en-US") + const imageBase64 = await getImageBase64(image || src) + + try { + const imageRes = await renderToImage( + +
+ +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} + +

+ {numberFormatter.format(feed.data.subscriptionCount)} followers with{" "} + {numberFormatter.format(feed.data.readCount)} recent reads on Follow +

+
+
, + { + width: 1200, + height: 600, + }, + ) + + return imageRes + } catch (err) { + console.error(err) + return null + } +} diff --git a/apps/server/src/router/og/index.ts b/apps/server/src/router/og/index.ts new file mode 100644 index 000000000..41a30a154 --- /dev/null +++ b/apps/server/src/router/og/index.ts @@ -0,0 +1,68 @@ +import { Readable } from "node:stream" + +import type { FastifyInstance, FastifyReply } from "fastify" + +import { createApiClient, getTokenFromCookie } from "~/lib/api-client" + +import { renderFeedOG } from "./feed" +import { renderListOG } from "./list" +import { renderUserOG } from "./user" + +export const ogRoute = (app: FastifyInstance) => { + app.get("/og/:type/:id", async (req, reply) => { + const { type, id } = req.params as Record + + const token = getTokenFromCookie(req.headers.cookie || "") + const apiClient = createApiClient(token) + let imageRes: { + image: Buffer + contentType: string + } | null = null + const errorFallback = createErrorFallback(reply) + + switch (type) { + case "feed": { + imageRes = await renderFeedOG(apiClient, id).catch(errorFallback) + + break + } + case "user": { + imageRes = await renderUserOG(apiClient, id).catch(errorFallback) + break + } + case "list": { + imageRes = await renderListOG(apiClient, id).catch(errorFallback) + break + } + default: { + return reply.code(404).send("Not found") + } + } + + if (!imageRes) { + if (!reply.sent) { + return reply.code(404).send("Not found") + } + return + } + + const stream = new Readable({ + read() { + this.push(imageRes.image) + this.push(null) + }, + }) + + reply.type(imageRes.contentType).headers({ + "Cache-Control": "max-age=3600, s-maxage=3600, stale-while-revalidate=600", + "Cloudflare-CDN-Cache-Control": "max-age=3600, s-maxage=3600, stale-while-revalidate=600", + "CDN-Cache-Control": "max-age=3600, s-maxage=3600, stale-while-revalidate=600", + }) + return reply.send(stream) + }) +} + +const createErrorFallback = (reply: FastifyReply) => (code: number) => { + reply.code(code).send("Internal server error") + return null +} diff --git a/apps/server/src/router/og/list.tsx b/apps/server/src/router/og/list.tsx new file mode 100644 index 000000000..27847b0be --- /dev/null +++ b/apps/server/src/router/og/list.tsx @@ -0,0 +1,107 @@ +import { getFeedIconSrc } from "@follow/components/utils/icon.js" +import * as React from "react" + +import type { ApiClient } from "~/lib/api-client" +import { renderToImage } from "~/lib/og/render-to-image" + +import { getImageBase64, OGAvatar, OGCanvas } from "./__base" + +export const renderListOG = async (apiClient: ApiClient, listId: string) => { + const feed = await apiClient.lists.$get({ query: { listId } }).catch(() => null) + + if (!feed?.data.list) { + throw 404 + } + + const { title, description, image } = feed.data.list + + const [src] = getFeedIconSrc({ + proxy: { + width: 256, + height: 256, + }, + fallback: true, + src: image!, + }) + + const numberFormatter = new Intl.NumberFormat("en-US") + let imageBase64: string | null = null + + if (src) { + imageBase64 = await getImageBase64(src) + } + + try { + const imageRes = await renderToImage( + +
+ +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} + +

+ {numberFormatter.format(feed.data.subscriptionCount)} followers with{" "} + {numberFormatter.format(feed.data.readCount)} recent reads on Follow +

+
+
, + { + width: 1200, + height: 600, + }, + ) + + return imageRes + } catch (err) { + console.error(err) + return null + } +} diff --git a/apps/server/src/router/og/user.tsx b/apps/server/src/router/og/user.tsx new file mode 100644 index 000000000..afd1d0aab --- /dev/null +++ b/apps/server/src/router/og/user.tsx @@ -0,0 +1,83 @@ +import { isBizId } from "@follow/utils/utils" +import * as React from "react" + +import type { ApiClient } from "~/lib/api-client" +import { renderToImage } from "~/lib/og/render-to-image" + +import { getImageBase64, OGAvatar, OGCanvas } from "./__base" + +export const renderUserOG = async (apiClient: ApiClient, id: string) => { + const handle = isBizId(id || "") ? id : `${id}`.startsWith("@") ? `${id}`.slice(1) : id + + const user = await apiClient.profiles.$get({ + query: { + handle, + id: isBizId(handle || "") ? handle : undefined, + }, + }) + + if (!user) { + throw 404 + } + + const imageBase64 = await getImageBase64(user.data.image) + + return await renderToImage( + +
+ +
+ +
+

+ {user.data.name} +

+ {user.data.handle && ( +

+ @{user.data.handle} +

+ )} +
+
, + { + width: 1200, + height: 600, + }, + ) +} diff --git a/apps/server/src/vercel-rewrite.json b/apps/server/src/vercel-rewrite.json new file mode 100644 index 000000000..c9585930d --- /dev/null +++ b/apps/server/src/vercel-rewrite.json @@ -0,0 +1,52 @@ +{ + "headers": { + "dnt": "1", + "forwarded": "for=38.181.79.248;host=follow-topaz.vercel.app;proto=https;sig=0QmVhcmVyIDViNmI2MzcwZmQzZmU3MTcyZDNmYzNjYWYxNzMzYTM3Njc1N2MzYWQwYmUzNTlmNGYxYzk3ZGQ2YjY0N2JmMDQ=;exp=1729144861", + "x-real-ip": "38.181.79.248", + "x-vercel-edge-region": "hkg1", + "x-vercel-id": "hkg1:hkg1:hkg1::79hps-1729144561999-93eaf829e1eb", + "upgrade-insecure-requests": "1", + "cf-visitor": "{\"scheme\":\"https\"}", + "cookie": "__vercel_toolbar=1", + "x-middleware-subrequest": "931b984572b208c96d0b50809260502e6854440e", + "x-vercel-forwarded-for": "38.181.79.248", + "cf-ew-via": "15", + "sec-ch-ua-mobile": "?0", + "sec-fetch-mode": "navigate", + "x-vercel-ip-country-region": "NKT", + "x-vercel-ip-timezone": "Asia/Hong_Kong", + "x-vercel-ja4-digest": "t13d1412h2_e33ad33b3d25_6b314db333b6", + "x-vercel-skip-toolbar": "1", + "sec-fetch-dest": "document", + "x-vercel-ip-as-number": "138997", + "host": "follow-topaz.vercel.app", + "priority": "u=0, i", + "accept-encoding": "gzip", + "sec-fetch-site": "none", + "sec-ch-ua": "\"Google Chrome\";v=\"129\", \"Not=A?Brand\";v=\"8\", \"Chromium\";v=\"129\"", + "cf-worker": "cloudflare-workers.vercel-infra-production.com", + "sec-fetch-user": "?1", + "x-forwarded-proto": "https", + "x-vercel-ip-city": "Ha%20Kwai%20Chung", + "x-forwarded-host": "follow-topaz.vercel.app", + "x-vercel-ip-continent": "AS", + "x-vercel-ip-latitude": "22.3539", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "cdn-loop": "cloudflare; loops=1; subreqs=1", + "cf-ray": "8d3e0b0865c1dd4b-HKG", + "x-vercel-deployment-url": "follow-l3l4vb81x-inneis-projects.vercel.app", + "x-vercel-proxy-signature": "Bearer 5b6b6370fd3fe7172d3fc3caf1733a376757c3ad0be359f4f1c97dd6b647bf04", + "x-forwarded-for": "38.181.79.248", + "x-vercel-ip-country": "HK", + "x-vercel-ip-longitude": "114.1342", + "x-vercel-proxy-signature-ts": "1729144861", + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "cf-connecting-ip": "38.181.79.248", + "x-vercel-proxied-for": "162.158.178.107", + "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", + "sec-ch-ua-platform": "\"macOS\"", + "connection": "close" + }, + "url": "/share/feeds/1/og/feed/1", + "ip": "127.0.0.1" +} diff --git a/apps/server/tailwind.config.ts b/apps/server/tailwind.config.ts new file mode 100644 index 000000000..a19672cb3 --- /dev/null +++ b/apps/server/tailwind.config.ts @@ -0,0 +1,71 @@ +import daisyui from "daisyui" +import { omit } from "lodash-es" +import resolveConfig from "tailwindcss/resolveConfig" + +import { baseConfig } from "../../configs/tailwind.base.config" +/** @type {import('tailwindcss').Config} */ +export default resolveConfig({ + ...baseConfig, + theme: { + ...baseConfig.theme, + extend: { + ...baseConfig.theme.extend, + colors: { + ...omit(baseConfig.theme.extend.colors, "accent"), + }, + }, + }, + content: [ + "./client/**/*.{ts,tsx}", + "./index.html", + "./node_modules/@follow/components/**/*.{ts,tsx}", + ], + plugins: [...baseConfig.plugins, daisyui], + daisyui: { + logs: false, + darkTheme: "dark", + themes: [ + { + light: { + "color-scheme": "light", + + primary: "#007AFF", //#0A84FF + accent: "#ff760a", + "accent-content": "#fff", + neutral: "#212427", + + "base-100": "#fff", + "base-content": "#0C0A09", + + info: "#32ADE6", // 50 173 230 + success: "#34C759", + warning: "#ff9f0a", + error: "#ff453a", // 255 59 48 + + "--rounded-btn": "1.9rem", + "--tab-border": "2px", + "--tab-radius": ".5rem", + }, + dark: { + "color-scheme": "dark", + + primary: "#0A84FF", + accent: "#ff760a", + "accent-content": "#fff", + neutral: "#2a2a2a", + "base-100": "#121212", + "base-content": "#FAFAF9", + + info: "#32ADE6", // 50 173 230 + success: "#34C759", + warning: "#ff9f0a", + error: "#ff453a", // 255 59 48 + + "--rounded-btn": "1.9rem", + "--tab-border": "2px", + "--tab-radius": ".5rem", + }, + }, + ], + }, +}) diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 000000000..613217936 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES2022", + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "moduleResolution": "Bundler", + "esModuleInterop": true, + "module": "esnext", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "types": ["vite/client", "@follow/types/react", "@follow/types/global"], + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "~/*": ["./src/*"], + "@client/*": ["./client/*"] + } + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.tsx", + "./index.ts", + "./api/index.ts", + "./scripts/**/*.ts", + "./tsup.config.ts", + "./client/**/*.ts", + "./client/**/*.tsx", + "vite.config.mts", + "./tailwind.config.ts" + ], + "exclude": ["node_modules"] +} diff --git a/apps/server/tsup.config.ts b/apps/server/tsup.config.ts new file mode 100644 index 000000000..bab5d123d --- /dev/null +++ b/apps/server/tsup.config.ts @@ -0,0 +1,43 @@ +import fs from "node:fs" +import { createRequire } from "node:module" +import path, { resolve } from "node:path" + +import { defineConfig } from "tsup" + +export default defineConfig(() => { + return { + entry: ["index.ts"], + outDir: "dist/server", + splitting: false, + clean: true, + format: ["cjs"], + external: ["lightningcss"], + treeshake: true, + define: { + __DEV__: JSON.stringify(process.env.NODE_ENV === "development"), + }, + banner: { + // A trick to get vercel to recognize fonts as such and then keep the node_modules + js: + process.env.VERCEL === "1" + ? ` + try { + require.resolve("@fontsource/sn-pro") + require.resolve('kose-font') + require.resolve('kose-font/fonts/KosefontP-JP.ttf') + require.resolve('kose-font/fonts/Kosefont-JP.ttf') + ${(() => { + const require = createRequire(import.meta.url) + const fontDepsPath = require.resolve("@fontsource/sn-pro") + const fontsDirPath = resolve(fontDepsPath, "../files") + return fs + .readdirSync(path.join(fontsDirPath)) + .map((file) => `require.resolve("@fontsource/sn-pro/files/${file}")`) + .join("\n") + })()} + } catch {} + ` + : ``, + }, + } +}) diff --git a/apps/server/vercel.json b/apps/server/vercel.json new file mode 100644 index 000000000..7e5aec0fb --- /dev/null +++ b/apps/server/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/((?!external-dist).*)", + "destination": "/api" + } + ] +} diff --git a/apps/server/vite.config.mts b/apps/server/vite.config.mts new file mode 100644 index 000000000..afa28f8b9 --- /dev/null +++ b/apps/server/vite.config.mts @@ -0,0 +1,35 @@ +import { resolve } from "node:path" + +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +import { viteRenderBaseConfig } from "../../configs/vite.render.config" +import { twMacro } from "../../plugins/vite/tw-macro" + +export default () => { + return defineConfig({ + resolve: { + alias: { + "@pkg": resolve(__dirname, "../../package.json"), + "@client": resolve(__dirname, "./client"), + }, + }, + define: { + ...viteRenderBaseConfig.define, + ELECTRON: "false", + }, + plugins: [react(), twMacro()], + + server: { + proxy: { + "/api": { + target: "https://api.follow.is", + changeOrigin: true, + rewrite(path) { + return path.replace("/api", "") + }, + }, + }, + }, + }) +} diff --git a/configs/tailwind.base.config.ts b/configs/tailwind.base.config.ts index 06ac5971d..3ba90b42e 100644 --- a/configs/tailwind.base.config.ts +++ b/configs/tailwind.base.config.ts @@ -8,7 +8,7 @@ import { compareColors, stringToColor } from "@iconify/utils/lib/colors" import type { Config } from "tailwindcss" import plugin from "tailwindcss/plugin" -export const baseConfig: Config = { +export const baseConfig = { darkMode: ["class", '[data-theme="dark"]'], content: [], prefix: "", @@ -179,7 +179,7 @@ export const baseConfig: Config = { ) }), ], -} +} satisfies Config function getCollections(dir: string) { // Import icons diff --git a/configs/vite.render.config.ts b/configs/vite.render.config.ts index 0e6567d18..7881ad592 100644 --- a/configs/vite.render.config.ts +++ b/configs/vite.render.config.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs" -import { resolve } from "node:path" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" import { sentryVitePlugin } from "@sentry/vite-plugin" import react from "@vitejs/plugin-react" @@ -12,7 +13,8 @@ import { twMacro } from "../plugins/vite/tw-macro" import i18nCompleteness from "../plugins/vite/utils/i18n-completeness" import { getGitHash } from "../scripts/lib" -const pkg = JSON.parse(readFileSync("package.json", "utf8")) +const pkgDir = dirname(fileURLToPath(import.meta.url)) +const pkg = JSON.parse(readFileSync(resolve(pkgDir, "../package.json"), "utf8")) const isCI = process.env.CI === "true" || process.env.CI === "1" export const viteRenderBaseConfig = { diff --git a/locales/app/en.json b/locales/app/en.json index 0405f7040..672190049 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -115,6 +115,12 @@ "entry_list_header.switch_to_normalmode": "Switch to Double Column Mode", "entry_list_header.switch_to_widemode": "Switch to Wide Mode", "entry_list_header.unread": "unread", + "feed.follower_one": "follower", + "feed.follower_other": "followers", + "feed.followsAndFeeds": "{{subscriptionCount}} {{subscriptionNoun}} and {{feedsCount}} {{feedsNoun}} on {{appName}}", + "feed.followsAndReads": "{{subscriptionCount}} {{subscriptionNoun}} with {{readCount}} recent {{readNoun}} on {{appName}}", + "feed.read_one": "read", + "feed.read_other": "reads", "feed_claim_modal.choose_verification_method": "There are three ways to choose from, you can choose one of them to verify.", "feed_claim_modal.claim_button": "Claim", "feed_claim_modal.content_instructions": "Copy the content below and post it to your latest RSS feed.", diff --git a/locales/external/en.json b/locales/external/en.json index 09d413db7..b33f5e72f 100644 --- a/locales/external/en.json +++ b/locales/external/en.json @@ -12,6 +12,12 @@ "feed.read_one": "read", "feed.read_other": "reads", "feed.view_feed_url": "View Feed URL", + "feed_item.claimed_by_owner": "This feed is claimed by", + "feed_item.claimed_by_unknown": "its owner.", + "feed_item.claimed_by_you": "Claimed by you", + "feed_item.claimed_feed": "Claimed Feed", + "feed_item.error_since": "Error since", + "feed_item.not_publicly_visible": "Not publicly visible on your profile page", "header.app": "App", "header.download": "Download", "invitation.activate": "Activate", diff --git a/package.json b/package.json index d0cb442bf..40df62b25 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "bump": "vv", "dev": "electron-vite dev", "dev:debug": "export DEBUG=true && vite --debug", + "dev:server": "pnpm run --filter=server dev", "dev:web": "vite", "format": "prettier --write .", "format:check": "prettier --check .", @@ -80,7 +81,7 @@ "eslint-config-hyoban": "^3.1.11", "fake-indexeddb": "6.0.0", "happy-dom": "15.7.4", - "hono": "4.6.3", + "hono": "4.6.5", "lint-staged": "15.2.10", "nbump": "2.0.7", "postcss": "8.4.47", @@ -108,7 +109,8 @@ "re-resizable": "patches/re-resizable@6.9.17.patch", "hono": "patches/hono.patch", "electron-context-menu": "patches/electron-context-menu.patch", - "@mozilla/readability": "patches/@mozilla__readability.patch" + "@mozilla/readability": "patches/@mozilla__readability.patch", + "daisyui": "patches/daisyui.patch" }, "overrides": { "is-core-module": "npm:@nolyfill/is-core-module@^1", diff --git a/packages/atoms/package.json b/packages/atoms/package.json new file mode 100644 index 000000000..045a7d816 --- /dev/null +++ b/packages/atoms/package.json @@ -0,0 +1,20 @@ +{ + "name": "@follow/atoms", + "version": "0.0.1", + "exports": { + "./atoms/*": { + "import": "./src/atoms/*", + "types": "./src/atoms/*" + }, + "./helper/*": { + "import": "./src/helper/*", + "types": "./src/helper/*" + } + }, + "dependencies": { + "@follow/hooks": "workspace:*", + "@follow/types": "workspace:*", + "@follow/utils": "workspace:*", + "jotai": "2.10.1" + } +} diff --git a/packages/atoms/src/atoms/user.ts b/packages/atoms/src/atoms/user.ts new file mode 100644 index 000000000..f6704c8ee --- /dev/null +++ b/packages/atoms/src/atoms/user.ts @@ -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>()) + +export const [, , useLoginModalShow, useSetLoginModalShow, getLoginModalShow, setLoginModalShow] = + createAtomHooks(atom(false)) diff --git a/packages/atoms/src/helper/setting.ts b/packages/atoms/src/helper/setting.ts new file mode 100644 index 000000000..69ec96555 --- /dev/null +++ b/packages/atoms/src/helper/setting.ts @@ -0,0 +1,94 @@ +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 = ( + settingKey: string, + createDefaultSettings: () => T, +) => { + const atom = atomWithStorage(getStorageNS(settingKey), createDefaultSettings(), undefined, { + getOnInit: true, + }) + + const [, , useSettingValue, , getSettings, setSettings] = createAtomHooks(atom as any) + + const initializeDefaultSettings = () => { + const currentSettings = getSettings() + const defaultSettings = createDefaultSettings() + if (typeof currentSettings !== "object") setSettings(defaultSettings) + const newSettings = { ...defaultSettings, ...currentSettings } + setSettings(newSettings) + } + + const selectAtomCacheMap = {} as Record, any> + + const useSettingKey = >(key: T) => { + let selectedAtom = selectAtomCacheMap[key] + if (!selectedAtom) { + selectedAtom = selectAtom(atom, (s) => s[key]) + selectAtomCacheMap[key] = selectedAtom + } + + return useAtomValue(selectedAtom) as ReturnType[T] + } + + const useSettingSelector = < + T extends keyof ReturnType, + S extends ReturnType, + R = S[T], + >( + selector: (s: S) => R, + ): R => { + const stableSelector = useRefValue(selector) + // @ts-expect-error + return useAtomValue(useMemo(() => selectAtom(atom, stableSelector.current), [stableSelector])) + } + + const setSetting = >( + key: K, + value: ReturnType[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>>(key: T) => Awaited + } + getSettings: typeof getSettings + settingAtom: typeof atom + } +} diff --git a/packages/atoms/tsconfig.json b/packages/atoms/tsconfig.json new file mode 100644 index 000000000..d988fd4fa --- /dev/null +++ b/packages/atoms/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.extend.json", + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "types": ["@follow/types/react", "@follow/types/global", "vite/client"], + "paths": { + "@follow/atoms/*": ["./src/*"], + "@pkg": ["../../package.json"] + } + }, + "include": ["src/**/*"] +} diff --git a/packages/components/assets/colors.css b/packages/components/assets/colors.css new file mode 100644 index 000000000..ea469772d --- /dev/null +++ b/packages/components/assets/colors.css @@ -0,0 +1,70 @@ +[data-theme="light"] { + --fo-a: 21.6 100% 50%; + + --fo-vibrancy-background: theme(colors.native.active); + --fo-vibrancy-foreground: 0 0% 45%; + + --fo-text-primary: 0 0% 10%; + + --fo-item-active: theme(colors.zinc.400/0.3); + --fo-item-hover: theme(colors.zinc.400/0.2); + + --fo-inactive: 0 0% 80%; + --fo-disabled: 0 0% 70%; + + --fo-background: theme(colors.background); + + --fo-modal-background: theme(colors.zinc.50/0.8); + --fo-modal-background-opaque: theme(colors.zinc.50); + + --fo-text-primary-hover: 0 0% 14.9%; + + --fo-button-hover: theme(colors.zinc.500/0.1); + --fo-image-placeholder: theme(colors.zinc.100); + --fo-text-placeholder: theme(colors.zinc.400); +} + +[data-theme="dark"] { + --fo-a: 21.6 100% 50%; + + --fo-vibrancy-foreground: 0 0% 83%; + --fo-vibrancy-background: theme(colors.native.active); + + --fo-text-primary: 0 0% 80%; + + --fo-item-active: theme(colors.neutral.600/0.4); + --fo-item-hover: theme(colors.neutral.700/0.3); + + --fo-inactive: 0 0% 50%; + --fo-disabled: 0 0% 35%; + + --fo-background: theme(colors.background); + + --fo-modal-background: theme(colors.neutral.900/0.8); + --fo-modal-background-opaque: theme(colors.neutral.900); + + --fo-text-primary-hover: 240 4.9% 83.9%; + + --fo-button-hover: theme(colors.neutral.400/0.15); + --fo-image-placeholder: theme(colors.neutral.800); + --fo-text-placeholder: theme(colors.zinc.500); +} + +[data-theme="light"] { + --fo-native: 240 1.6% 87.6%; + --fo-native-active: 240 0.9% 78.2%; +} +[data-theme="dark"] { + --fo-native: 30 1.7% 23.5%; + --fo-native-active: 20 2.1% 28.4%; +} + +[data-build-type="web"] { + --fo-native: 240 4.8% 95.9%; + --fo-native-active: 240 4.9% 83.9%; +} + +[data-build-type="web"][data-theme="dark"] { + --fo-native: 220 8.1% 14.5%; + --fo-native-active: 198 31.3% 6.3%; +} diff --git a/packages/components/assets/font.css b/packages/components/assets/font.css new file mode 100644 index 000000000..e4e60e3d3 --- /dev/null +++ b/packages/components/assets/font.css @@ -0,0 +1,16 @@ +@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"; diff --git a/packages/components/assets/index.css b/packages/components/assets/index.css new file mode 100644 index 000000000..849b5eb84 --- /dev/null +++ b/packages/components/assets/index.css @@ -0,0 +1,3 @@ +@import "./colors.css"; +@import "./tailwind.css"; +@import "./font.css"; diff --git a/packages/components/assets/tailwind.css b/packages/components/assets/tailwind.css new file mode 100644 index 000000000..5131010d2 --- /dev/null +++ b/packages/components/assets/tailwind.css @@ -0,0 +1,70 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + /* shadcn colors */ + + :root, + #shadow-html { + --radius: 0.5rem; + } + + [data-theme="light"] { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 20 5.9% 90%; + --radius: 0.5rem; + } + + [data-theme="dark"] { + --background: 0 0% 7.1%; + --foreground: 60 9.1% 97.8%; + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + --border: 0 0% 22.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply text-theme-foreground; + } +} + +.prose { + table * { + font-size: 1rem; + } + + table table { + margin: 0; + } + + p { + @apply break-words; + } + + word-break: break-word; +} diff --git a/packages/components/package.json b/packages/components/package.json new file mode 100644 index 000000000..e38546013 --- /dev/null +++ b/packages/components/package.json @@ -0,0 +1,61 @@ +{ + "name": "@follow/components", + "version": "0.0.1", + "private": true, + "exports": { + "./tailwind": "./assets/index.css", + "./modules/*": "./src/modules/*", + "./common/*": "./src/common/*", + "./icons/*": "./src/icons/*", + "./ui/*": "./src/ui/*", + "./providers/*": "./src/providers/*", + "./hooks/*": "./src/hooks/*", + "./atoms/*": "./src/atoms/*", + "./dayjs": "./src/utils/dayjs.ts", + "./utils/*": "./src/utils/*" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "dependencies": { + "@essentials/request-timeout": "1.3.0", + "@follow/hooks": "workspace:*", + "@follow/models": "workspace:*", + "@follow/shared": "workspace:*", + "@follow/types": "workspace:*", + "@follow/utils": "workspace:*", + "@headlessui/react": "2.1.10", + "@radix-ui/react-avatar": "1.1.1", + "@radix-ui/react-checkbox": "1.1.2", + "@radix-ui/react-context-menu": "2.2.2", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-dropdown-menu": "2.1.2", + "@radix-ui/react-hover-card": "1.1.2", + "@radix-ui/react-label": "2.1.0", + "@radix-ui/react-navigation-menu": "1.2.1", + "@radix-ui/react-popover": "1.1.2", + "@radix-ui/react-radio-group": "1.2.1", + "@radix-ui/react-scroll-area": "1.2.0", + "@radix-ui/react-select": "2.1.2", + "@radix-ui/react-slider": "^1.2.1", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-switch": "1.1.1", + "@radix-ui/react-tabs": "1.1.1", + "@radix-ui/react-toast": "1.2.2", + "@radix-ui/react-tooltip": "1.1.3", + "@react-hook/window-size": "3.1.1", + "class-variance-authority": "0.7.0", + "dayjs": "1.11.13", + "foxact": "0.2.39", + "framer-motion": "11.11.9", + "jotai": "2.10.1", + "lodash-es": "4.17.21", + "masonic": "4.0.1", + "react-blurhash": "^0.3.0", + "react-fast-marquee": "1.6.5", + "react-i18next": "^15.0.3", + "sonner": "^1.5.0", + "usehooks-ts": "3.1.0" + } +} diff --git a/packages/components/src/atoms/route.ts b/packages/components/src/atoms/route.ts new file mode 100644 index 000000000..b82bdd738 --- /dev/null +++ b/packages/components/src/atoms/route.ts @@ -0,0 +1,40 @@ +import { createAtomHooks } from "@follow/utils/jotai" +import { atom, useAtomValue } from "jotai" +import { selectAtom } from "jotai/utils" +import { useMemo } from "react" +import type { Location, NavigateFunction, Params } from "react-router-dom" + +interface RouteAtom { + params: Readonly> + searchParams: URLSearchParams + location: Location +} + +export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks( + atom({ + params: {}, + searchParams: new URLSearchParams(), + + location: { + pathname: "", + search: "", + hash: "", + state: null, + key: "", + }, + }), +) + +const noop = [] +export const useReadonlyRouteSelector = ( + selector: (route: RouteAtom) => T, + deps: any[] = noop, +): T => useAtomValue(useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps)) + +// Vite HMR will create new router instance, but RouterProvider always stable + +const [, , , , navigate, setNavigate] = createAtomHooks( + atom<{ fn: NavigateFunction | null }>({ fn() {} }), +) +const getStableRouterNavigate = () => navigate().fn +export { getStableRouterNavigate, setNavigate } diff --git a/packages/components/src/atoms/viewport.ts b/packages/components/src/atoms/viewport.ts new file mode 100644 index 000000000..02b82f760 --- /dev/null +++ b/packages/components/src/atoms/viewport.ts @@ -0,0 +1,31 @@ +import { atom } from "jotai" + +export const viewportAtom = atom({ + /** + * 640px + */ + sm: false, + + /** + * 768px + */ + md: false, + + /** + * 1024px + */ + lg: false, + + /** + * 1280px + */ + xl: false, + + /** + * 1536px + */ + "2xl": false, + + h: 0, + w: 0, +}) diff --git a/packages/components/src/common/MemoedDangerousHTMLStyle.tsx b/packages/components/src/common/MemoedDangerousHTMLStyle.tsx new file mode 100644 index 000000000..cad4cf7d5 --- /dev/null +++ b/packages/components/src/common/MemoedDangerousHTMLStyle.tsx @@ -0,0 +1,19 @@ +import type { FC } from "react" +import { memo, useMemo } from "react" + +export const MemoedDangerousHTMLStyle: FC< + { + children: string + } & React.DetailedHTMLProps, HTMLStyleElement> & + Record +> = memo(({ children, ...rest }) => ( +