diff --git a/.github/workflows/create_heroku_review_app.yaml b/.github/workflows/create_heroku_review_app.yaml index 61605fb65..56caa2cdf 100644 --- a/.github/workflows/create_heroku_review_app.yaml +++ b/.github/workflows/create_heroku_review_app.yaml @@ -1,13 +1,40 @@ name: Review App on: - pull_request_target: - types: [opened] + pull_request: + types: [opened, synchronize] jobs: create-review-app: runs-on: ubuntu-latest steps: - - uses: fastruby/manage-heroku-review-app@9fa49f0320460f278c3687bc348dd0cbb18555dc # v1.3 + - name: Get PR Number + id: get_pr_number + run: echo "::set-output name=pr_number::${{ github.event.pull_request.number }}" + + - name: Check if PR Number is greater than 140 + id: set_step_id + run: | + pr_number=${{ steps.get_pr_number.outputs.pr_number }} + if [ $pr_number -gt 140 ]; then + echo "::set-output name=step_id::true" + else + echo "::set-output name=step_id::false" + fi + + - name: Display step_id + run: echo "Step ID is ${{ steps.set_step_id.outputs.step_id }}" + + - uses: kqito/manage-heroku-review-app@55e434ad5ac86f21cf2f7654de1566973fbc7046 + if: ${{ steps.set_step_id.outputs.step_id == 'true' }} + with: + action: destroy + env: + HEROKU_API_TOKEN: ${{ secrets.HEROKU_API_TOKEN }} + HEROKU_PIPELINE_ID: ${{ secrets.HEROKU_PIPELINE_ID }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - uses: kqito/manage-heroku-review-app@55e434ad5ac86f21cf2f7654de1566973fbc7046 + if: ${{ steps.set_step_id.outputs.step_id == 'true' }} with: action: create env: diff --git a/.github/workflows/destroy_heroku_review_app.yaml b/.github/workflows/destroy_heroku_review_app.yaml index b2bf67949..cbcec744a 100644 --- a/.github/workflows/destroy_heroku_review_app.yaml +++ b/.github/workflows/destroy_heroku_review_app.yaml @@ -7,7 +7,7 @@ jobs: destroy-review-app: runs-on: ubuntu-latest steps: - - uses: fastruby/manage-heroku-review-app@9fa49f0320460f278c3687bc348dd0cbb18555dc # v1.3 + - uses: kqito/manage-heroku-review-app@55e434ad5ac86f21cf2f7654de1566973fbc7046 with: action: destroy env: diff --git a/package.json b/package.json index 88d45e67d..0074ce490 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "test": "wireit" }, "dependencies": { - "wireit": "0.14.9" + "wireit": "0.14.11" }, "devDependencies": { "@wsh-2025/configs": "workspace:*" @@ -58,5 +58,8 @@ "./workspaces/test:test" ] } + }, + "volta": { + "node": "22.14.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 576486ce7..4e6a03b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: .: dependencies: wireit: - specifier: 0.14.9 - version: 0.14.9 + specifier: 0.14.11 + version: 0.14.11 devDependencies: '@wsh-2025/configs': specifier: workspace:* @@ -5256,6 +5256,11 @@ packages: wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + wireit@0.14.11: + resolution: {integrity: sha512-edO3AiL1wYlBIapwx8e1OGEmsG37sv7qnRzx4YDsMPcKo+6NMOjWYcfRbaeoB1MzakLrnozWB2Q1q7Ko45NckA==} + engines: {node: '>=18.0.0'} + hasBin: true + wireit@0.14.9: resolution: {integrity: sha512-hFc96BgyslfO1WGSzQqOVYd5N3TB+4u9w70L9GHR/T7SYjvFmeznkYMsRIjMLhPcVabCEYPW1vV66wmIVDs+dQ==} engines: {node: '>=18.0.0'} @@ -10488,6 +10493,14 @@ snapshots: wildcard@2.0.1: {} + wireit@0.14.11: + dependencies: + brace-expansion: 4.0.0 + chokidar: 3.6.0 + fast-glob: 3.3.2 + jsonc-parser: 3.3.1 + proper-lockfile: 4.1.2 + wireit@0.14.9: dependencies: brace-expansion: 4.0.0 diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..834a581ba --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,41 @@ +// workspaces/client/public/service-worker.js +const CACHE_NAME = 'arema-streams-cache-v1'; + +self.addEventListener('install', (event) => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((name) => { + if (name !== CACHE_NAME) { + return caches.delete(name); + } + }), + ); + }), + ); +}); + +self.addEventListener('fetch', (event) => { + const requestUrl = new URL(event.request.url); + // /streams/ に対するリクエストのみキャッシュ対象とする + if (requestUrl.pathname.startsWith('/streams/')) { + event.respondWith( + caches.match(event.request).then((response) => { + if (response) { + return response; + } + return fetch(event.request).then((networkResponse) => { + const responseClone = networkResponse.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + return networkResponse; + }); + }), + ); + } +}); diff --git a/workspaces/client/src/app/Document.tsx b/workspaces/client/src/app/Document.tsx index 0ca7b8252..28f87bc1c 100644 --- a/workspaces/client/src/app/Document.tsx +++ b/workspaces/client/src/app/Document.tsx @@ -18,7 +18,7 @@ export const Document = () => { - + Loading...}> diff --git a/workspaces/client/src/app/createRoutes.tsx b/workspaces/client/src/app/createRoutes.tsx index a81e12561..fe80d5f48 100644 --- a/workspaces/client/src/app/createRoutes.tsx +++ b/workspaces/client/src/app/createRoutes.tsx @@ -1,7 +1,7 @@ -import lazy from 'p-min-delay'; +import React from 'react'; import { RouteObject } from 'react-router'; -import { Document, prefetch } from '@wsh-2025/client/src/app/Document'; +import { Document, prefetch as documentPrefetch } from '@wsh-2025/client/src/app/Document'; import { createStore } from '@wsh-2025/client/src/app/createStore'; export function createRoutes(store: ReturnType): RouteObject[] { @@ -10,99 +10,85 @@ export function createRoutes(store: ReturnType): RouteObject children: [ { index: true, - async lazy() { - const { HomePage, prefetch } = await lazy( - import('@wsh-2025/client/src/pages/home/components/HomePage'), - 1000, + lazy: async () => { + const module = await import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/home/components/HomePage' ); return { - Component: HomePage, - async loader() { - return await prefetch(store); - }, + Component: module.HomePage, + loader: async () => await module.prefetch(store), }; }, }, { - async lazy() { - const { EpisodePage, prefetch } = await lazy( - import('@wsh-2025/client/src/pages/episode/components/EpisodePage'), - 1000, + lazy: async () => { + const module = await import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/episode/components/EpisodePage' ); return { - Component: EpisodePage, - async loader({ params }) { - return await prefetch(store, params); - }, + Component: module.EpisodePage, + loader: async ({ params }) => await module.prefetch(store, params), }; }, path: '/episodes/:episodeId', }, { - async lazy() { - const { prefetch, ProgramPage } = await lazy( - import('@wsh-2025/client/src/pages/program/components/ProgramPage'), - 1000, + lazy: async () => { + const module = await import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/program/components/ProgramPage' ); return { - Component: ProgramPage, - async loader({ params }) { - return await prefetch(store, params); - }, + Component: module.ProgramPage, + loader: async ({ params }) => await module.prefetch(store, params), }; }, path: '/programs/:programId', }, { - async lazy() { - const { prefetch, SeriesPage } = await lazy( - import('@wsh-2025/client/src/pages/series/components/SeriesPage'), - 1000, + lazy: async () => { + const module = await import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/series/components/SeriesPage' ); return { - Component: SeriesPage, - async loader({ params }) { - return await prefetch(store, params); - }, + Component: module.SeriesPage, + loader: async ({ params }) => await module.prefetch(store, params), }; }, path: '/series/:seriesId', }, { - async lazy() { - const { prefetch, TimetablePage } = await lazy( - import('@wsh-2025/client/src/pages/timetable/components/TimetablePage'), - 1000, + lazy: async () => { + const module = await import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/timetable/components/TimetablePage' ); return { - Component: TimetablePage, - async loader() { - return await prefetch(store); - }, + Component: module.TimetablePage, + loader: async () => await module.prefetch(store), }; }, path: '/timetable', }, { - async lazy() { - const { NotFoundPage, prefetch } = await lazy( - import('@wsh-2025/client/src/pages/not_found/components/NotFoundPage'), - 1000, + lazy: async () => { + const module = await import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/not_found/components/NotFoundPage' ); return { - Component: NotFoundPage, - async loader() { - return await prefetch(store); - }, + Component: module.NotFoundPage, + loader: async () => await module.prefetch(store), }; }, path: '*', }, ], - Component: Document, - async loader() { - return await prefetch(store); - }, + element: , + loader: async () => await documentPrefetch(store), path: '/', }, ]; diff --git a/workspaces/client/src/features/auth/hooks/useAuthActions.ts b/workspaces/client/src/features/auth/hooks/useAuthActions.ts index 7ea329795..adb89b321 100644 --- a/workspaces/client/src/features/auth/hooks/useAuthActions.ts +++ b/workspaces/client/src/features/auth/hooks/useAuthActions.ts @@ -1,15 +1,17 @@ +import { useMemo } from 'react'; + import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useAuthActions() { - const state = useStore((s) => s); + const actions = useStore((s) => ({ + closeDialog: s.features.auth.closeDialog, + openSignInDialog: s.features.auth.openSignInDialog, + openSignOutDialog: s.features.auth.openSignOutDialog, + openSignUpDialog: s.features.auth.openSignUpDialog, + signIn: s.features.auth.signIn, + signOut: s.features.auth.signOut, + signUp: s.features.auth.signUp, + })); - return { - closeDialog: state.features.auth.closeDialog, - openSignInDialog: state.features.auth.openSignInDialog, - openSignOutDialog: state.features.auth.openSignOutDialog, - openSignUpDialog: state.features.auth.openSignUpDialog, - signIn: state.features.auth.signIn, - signOut: state.features.auth.signOut, - signUp: state.features.auth.signUp, - }; + return useMemo(() => actions, [actions]); } diff --git a/workspaces/client/src/features/auth/hooks/useAuthDialogType.ts b/workspaces/client/src/features/auth/hooks/useAuthDialogType.ts index 9350b3f82..184c92902 100644 --- a/workspaces/client/src/features/auth/hooks/useAuthDialogType.ts +++ b/workspaces/client/src/features/auth/hooks/useAuthDialogType.ts @@ -1,6 +1,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useAuthDialogType() { - const state = useStore((s) => s); - return state.features.auth.dialog; + return useStore((s) => s.features.auth.dialog); } diff --git a/workspaces/client/src/features/auth/hooks/useAuthUser.ts b/workspaces/client/src/features/auth/hooks/useAuthUser.ts index aa3a86707..21bc6a8e5 100644 --- a/workspaces/client/src/features/auth/hooks/useAuthUser.ts +++ b/workspaces/client/src/features/auth/hooks/useAuthUser.ts @@ -1,6 +1,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useAuthUser() { - const state = useStore((s) => s); - return state.features.auth.user; + return useStore((s) => s.features.auth.user); } diff --git a/workspaces/client/src/features/channel/hooks/useChannelById.ts b/workspaces/client/src/features/channel/hooks/useChannelById.ts index 9386928fe..1e038f1db 100644 --- a/workspaces/client/src/features/channel/hooks/useChannelById.ts +++ b/workspaces/client/src/features/channel/hooks/useChannelById.ts @@ -3,9 +3,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; type ChannelId = string; export function useChannelById(params: { channelId: ChannelId }) { - const state = useStore((s) => s); - - const channel = state.features.channel.channels[params.channelId]; - - return channel; + return useStore((s) => s.features.channel.channels[params.channelId]); } diff --git a/workspaces/client/src/features/layout/components/Hoverable.tsx b/workspaces/client/src/features/layout/components/Hoverable.tsx index b723d8aee..c23ff1883 100644 --- a/workspaces/client/src/features/layout/components/Hoverable.tsx +++ b/workspaces/client/src/features/layout/components/Hoverable.tsx @@ -1,11 +1,15 @@ import classNames from 'classnames'; -import { Children, cloneElement, ReactElement, Ref, useRef } from 'react'; +import React, { Children, cloneElement, ReactElement, Ref, useRef, useState } from 'react'; import { useMergeRefs } from 'use-callback-ref'; -import { usePointer } from '@wsh-2025/client/src/features/layout/hooks/usePointer'; +// 子要素の型を拡張して HTML 属性も許容する +interface HoverableChildProps extends React.HTMLAttributes { + className?: string; + ref?: Ref; +} interface Props { - children: ReactElement<{ className?: string; ref?: Ref }>; + children: ReactElement; classNames: { default?: string; hovered?: string; @@ -15,18 +19,16 @@ interface Props { export const Hoverable = (props: Props) => { const child = Children.only(props.children); const elementRef = useRef(null); - const mergedRef = useMergeRefs([elementRef, child.props.ref].filter((v) => v != null)); - const pointer = usePointer(); - const elementRect = elementRef.current?.getBoundingClientRect(); - - const hovered = - elementRect != null && - elementRect.left <= pointer.x && - pointer.x <= elementRect.right && - elementRect.top <= pointer.y && - pointer.y <= elementRect.bottom; + // マウスのエンター/リーブでホバー状態を管理 + const [hovered, setHovered] = useState(false); + const handleMouseEnter = () => { + setHovered(true); + }; + const handleMouseLeave = () => { + setHovered(false); + }; return cloneElement(child, { className: classNames( @@ -34,6 +36,8 @@ export const Hoverable = (props: Props) => { 'cursor-pointer', hovered ? props.classNames.hovered : props.classNames.default, ), + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, ref: mergedRef, }); }; diff --git a/workspaces/client/src/features/layout/components/Layout.tsx b/workspaces/client/src/features/layout/components/Layout.tsx index 99eadef4b..23d10e832 100644 --- a/workspaces/client/src/features/layout/components/Layout.tsx +++ b/workspaces/client/src/features/layout/components/Layout.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { ReactNode, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; import { Flipper } from 'react-flip-toolkit'; import { Link, useLocation, useNavigation } from 'react-router'; @@ -11,15 +11,74 @@ import { useAuthActions } from '@wsh-2025/client/src/features/auth/hooks/useAuth import { useAuthDialogType } from '@wsh-2025/client/src/features/auth/hooks/useAuthDialogType'; import { useAuthUser } from '@wsh-2025/client/src/features/auth/hooks/useAuthUser'; import { Loading } from '@wsh-2025/client/src/features/layout/components/Loading'; -import { useSubscribePointer } from '@wsh-2025/client/src/features/layout/hooks/useSubscribePointer'; interface Props { children: ReactNode; } -export const Layout = ({ children }: Props) => { - useSubscribePointer(); +const LogoLink = ({ onMouseEnter }: { onMouseEnter: () => void }) => ( + + AREMA + +); + +const NavLink = ({ + icon, + label, + onMouseEnter, + to, +}: { + icon: string; + label: string; + onMouseEnter: () => void; + to: string; +}) => ( + +
+ {label} + +); + +const Header = ({ className, onMouseEnterHome }: { className: string; onMouseEnterHome: () => void }) => ( +
+ +
+); + +const Navigation = ({ + isSignedIn, + onMouseEnterHome, + onMouseEnterTimetable, + onSignInOut, +}: { + isSignedIn: boolean; + onMouseEnterHome: () => void; + onMouseEnterTimetable: () => void; + onSignInOut: () => void; +}) => ( + +); +export const Layout = ({ children }: Props) => { const navigation = useNavigation(); const isLoading = navigation.location != null && (navigation.location.state as { loading?: string } | null)?.['loading'] !== 'none'; @@ -34,17 +93,16 @@ export const Layout = ({ children }: Props) => { const [scrollTopOffset, setScrollTopOffset] = useState(0); const [shouldHeaderBeTransparent, setShouldHeaderBeTransparent] = useState(false); - useEffect(() => { - const handleScroll = () => { - setScrollTopOffset(window.scrollY); - }; + const handleScroll = useCallback(() => { + setScrollTopOffset(window.scrollY); + }, []); + useEffect(() => { window.addEventListener('scroll', handleScroll); - return () => { window.removeEventListener('scroll', handleScroll); }; - }, []); + }, [handleScroll]); useEffect(() => { setShouldHeaderBeTransparent(scrollTopOffset > 80); @@ -52,53 +110,39 @@ export const Layout = ({ children }: Props) => { const isSignedIn = user != null; + const preloadHomePage = useCallback(() => { + void import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/home/components/HomePage' + ); + }, []); + + const preloadTimetablePage = useCallback(() => { + void import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/timetable/components/TimetablePage' + ); + }, []); + + const headerClassName = classNames( + 'sticky top-[0px] z-10 order-1 flex h-[80px] w-full flex-row [grid-area:a1/a1/b1/b1]', + !isLoading && shouldHeaderBeTransparent + ? 'bg-gradient-to-b from-[#171717] to-transparent' + : 'bg-gradient-to-b from-[#171717] to-[#171717]', + ); + return ( <>
-
- - AREMA - -
+
@@ -107,11 +151,11 @@ export const Layout = ({ children }: Props) => {
- {isLoading ? ( + {isLoading && (
- ) : null} + )}
s); - return s.features.layout.pointer; -} diff --git a/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts b/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts deleted file mode 100644 index 5dc37a174..000000000 --- a/workspaces/client/src/features/layout/hooks/useSubscribePointer.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect } from 'react'; - -import { useStore } from '@wsh-2025/client/src/app/StoreContext'; - -export function useSubscribePointer(): void { - const s = useStore((s) => s); - - useEffect(() => { - const abortController = new AbortController(); - - const current = { x: 0, y: 0 }; - const handlePointerMove = (ev: MouseEvent) => { - current.x = ev.clientX; - current.y = ev.clientY; - }; - window.addEventListener('pointermove', handlePointerMove, { signal: abortController.signal }); - - let immediate = setImmediate(function tick() { - s.features.layout.updatePointer({ ...current }); - immediate = setImmediate(tick); - }); - abortController.signal.addEventListener('abort', () => { - clearImmediate(immediate); - }); - - return () => { - abortController.abort(); - }; - }, []); -} diff --git a/workspaces/client/src/features/player/components/Player.tsx b/workspaces/client/src/features/player/components/Player.tsx index f1c27e8b9..cc6743125 100644 --- a/workspaces/client/src/features/player/components/Player.tsx +++ b/workspaces/client/src/features/player/components/Player.tsx @@ -1,4 +1,4 @@ -import { Ref, useEffect, useRef } from 'react'; +import { memo, Ref, useEffect, useMemo, useRef } from 'react'; import invariant from 'tiny-invariant'; import { assignRef } from 'use-callback-ref'; @@ -13,7 +13,7 @@ interface Props { playlistUrl: string; } -export const Player = ({ className, loop, playerRef, playerType, playlistUrl }: Props) => { +const PlayerComponent = ({ className, loop, playerRef, playerType, playlistUrl }: Props) => { const mountRef = useRef(null); useEffect(() => { @@ -41,10 +41,12 @@ export const Player = ({ className, loop, playerRef, playerType, playlistUrl }: } assignRef(playerRef, null); }; - }, [playerType, playlistUrl, loop]); + }, [playerType, playlistUrl, loop, playerRef]); + + const containerClassName = useMemo(() => className, [className]); return ( -
+
@@ -55,3 +57,7 @@ export const Player = ({ className, loop, playerRef, playerType, playlistUrl }:
); }; + +PlayerComponent.displayName = 'Player'; + +export const Player = memo(PlayerComponent); diff --git a/workspaces/client/src/features/recommended/components/EpisodeItem.tsx b/workspaces/client/src/features/recommended/components/EpisodeItem.tsx index 74b08745c..f56af0056 100644 --- a/workspaces/client/src/features/recommended/components/EpisodeItem.tsx +++ b/workspaces/client/src/features/recommended/components/EpisodeItem.tsx @@ -4,13 +4,18 @@ import { NavLink } from 'react-router'; import { Hoverable } from '@wsh-2025/client/src/features/layout/components/Hoverable'; +const preloadEpisodePage = () => { + void import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/episode/components/EpisodePage' + ); +}; + interface Props { episode: { id: string; premium: boolean; - series: { - title: string; - }; + series: { title: string }; thumbnailUrl: string; title: string; }; @@ -19,32 +24,35 @@ interface Props { export const EpisodeItem = ({ episode }: Props) => { return ( - - {({ isTransitioning }) => { - return ( - <> - -
- - - {episode.premium ? ( - - プレミアム - - ) : null} -
-
-
-
- -
-
- -
+ + {({ isTransitioning }) => ( + <> + +
+ + + {episode.premium && ( + + プレミアム + + )} +
+
+
+
+ +
+
+
- - ); - }} +
+ + )}
); diff --git a/workspaces/client/src/features/recommended/components/JumbotronSection.tsx b/workspaces/client/src/features/recommended/components/JumbotronSection.tsx index 638c9cfd6..75628816a 100644 --- a/workspaces/client/src/features/recommended/components/JumbotronSection.tsx +++ b/workspaces/client/src/features/recommended/components/JumbotronSection.tsx @@ -13,13 +13,19 @@ import { PlayerWrapper } from '../../player/interfaces/player_wrapper'; import { Hoverable } from '@wsh-2025/client/src/features/layout/components/Hoverable'; +const preloadEpisodePage = () => { + void import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/episode/components/EpisodePage' + ); +}; + interface Props { module: ArrayValues>; } export const JumbotronSection = ({ module }: Props) => { const playerRef = useRef(null); - const episode = module.items[0]?.episode; invariant(episode); @@ -29,33 +35,31 @@ export const JumbotronSection = ({ module }: Props) => { viewTransition className="block flex h-[260px] w-full flex-row items-center justify-center overflow-hidden rounded-[8px] bg-[#171717]" to={`/episodes/${episode.id}`} + onMouseEnter={preloadEpisodePage} > - {({ isTransitioning }) => { - return ( - <> -
-
- -
-
- -
+ {({ isTransitioning }) => ( + <> +
+
+
- - -
- -
-
- - ); - }} +
+ +
+
+ +
+ +
+
+ + )} ); diff --git a/workspaces/client/src/features/recommended/components/SeriesItem.tsx b/workspaces/client/src/features/recommended/components/SeriesItem.tsx index 2477b7a97..8a371580f 100644 --- a/workspaces/client/src/features/recommended/components/SeriesItem.tsx +++ b/workspaces/client/src/features/recommended/components/SeriesItem.tsx @@ -4,6 +4,13 @@ import { NavLink } from 'react-router'; import { Hoverable } from '@wsh-2025/client/src/features/layout/components/Hoverable'; +const preloadSeriesPage = () => { + void import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/series/components/SeriesPage' + ); +}; + interface Props { series: { id: string; @@ -15,23 +22,26 @@ interface Props { export const SeriesItem = ({ series }: Props) => { return ( - - {({ isTransitioning }) => { - return ( - <> -
- - - -
-
-
- -
+ + {({ isTransitioning }) => ( + <> +
+ + + +
+
+
+
- - ); - }} +
+ + )}
); diff --git a/workspaces/client/src/features/series/components/SeriesEposideItem.tsx b/workspaces/client/src/features/series/components/SeriesEposideItem.tsx index 0a0067d95..6c364ca00 100644 --- a/workspaces/client/src/features/series/components/SeriesEposideItem.tsx +++ b/workspaces/client/src/features/series/components/SeriesEposideItem.tsx @@ -4,6 +4,13 @@ import { NavLink } from 'react-router'; import { Hoverable } from '@wsh-2025/client/src/features/layout/components/Hoverable'; +const preloadEpisodePage = () => { + void import( + /* webpackPrefetch: true */ + '@wsh-2025/client/src/pages/episode/components/EpisodePage' + ); +}; + interface Props { episode: { description: string; @@ -22,33 +29,31 @@ export const SeriesEpisodeItem = ({ episode, selected }: Props) => { viewTransition className="block flex w-full flex-row items-start justify-between gap-x-[16px]" to={`/episodes/${episode.id}`} + onMouseEnter={preloadEpisodePage} > - {({ isTransitioning }) => { - return ( - <> - -
- - - {episode.premium ? ( - - プレミアム - - ) : null} -
-
- -
-
- -
-
- -
+ {({ isTransitioning }) => ( + <> + +
+ + + {episode.premium && ( + + プレミアム + + )} +
+
+
+
+ +
+
+
- - ); - }} +
+ + )} ); diff --git a/workspaces/client/src/main.tsx b/workspaces/client/src/main.tsx index 601b7a6a5..ac58d0241 100644 --- a/workspaces/client/src/main.tsx +++ b/workspaces/client/src/main.tsx @@ -15,6 +15,21 @@ declare global { var __staticRouterHydrationData: HydrationState; } +// Service Workerの登録 +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/service-worker.js') + .then((registration) => { + console.log('Service Worker registered with scope:', registration.scope); + }) + // eslint-disable-next-line @typescript-eslint/use-unknown-in-catch-callback-variable + .catch((error) => { + console.error('Service Worker registration failed:', error); + }); + }); +} + function main() { const store = createStore({}); const router = createBrowserRouter(createRoutes(store), {}); diff --git a/workspaces/client/src/pages/episode/components/EpisodePage.tsx b/workspaces/client/src/pages/episode/components/EpisodePage.tsx index 075eacc48..7c1eba203 100644 --- a/workspaces/client/src/pages/episode/components/EpisodePage.tsx +++ b/workspaces/client/src/pages/episode/components/EpisodePage.tsx @@ -29,7 +29,6 @@ export const prefetch = async (store: ReturnType, { episodeI export const EpisodePage = () => { const authActions = useAuthActions(); const user = useAuthUser(); - const { episodeId } = useParams(); invariant(episodeId); @@ -37,22 +36,18 @@ export const EpisodePage = () => { invariant(episode); const modules = useRecommended({ referenceId: episodeId }); - const playerRef = usePlayerRef(); - const isSignInRequired = episode.premium && user == null; return ( <> {`${episode.title} - ${episode.series.title} - AremaTV`} -
{isSignInRequired ? (
-

プレミアムエピソードの視聴にはログインが必要です @@ -89,7 +84,6 @@ export const EpisodePage = () => { playerType={PlayerType.HlsJS} playlistUrl={`/streams/episode/${episode.id}/playlist.m3u8`} /> -

@@ -98,7 +92,6 @@ export const EpisodePage = () => { )}
-
@@ -106,24 +99,22 @@ export const EpisodePage = () => {

- {episode.premium ? ( + {episode.premium && (
プレミアム
- ) : null} + )}
- - {modules[0] != null ? ( + {modules[0] != null && (
- ) : null} - + )}

エピソード

diff --git a/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx b/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx index b706a379b..91ded029c 100644 --- a/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx +++ b/workspaces/client/src/pages/episode/components/SeekThumbnail.tsx @@ -1,8 +1,10 @@ +/* eslint-disable eqeqeq */ +// workspaces/client/src/pages/episode/components/SeekThumbnail.tsx + import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import { useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; -import { usePointer } from '@wsh-2025/client/src/features/layout/hooks/usePointer'; import { useDuration } from '@wsh-2025/client/src/pages/episode/hooks/useDuration'; import { useSeekThumbnail } from '@wsh-2025/client/src/pages/episode/hooks/useSeekThumbnail'; @@ -15,16 +17,44 @@ interface Props { export const SeekThumbnail = ({ episode }: Props) => { const ref = useRef(null); const seekThumbnail = useSeekThumbnail({ episode }); - const pointer = usePointer(); const duration = useDuration(); + // state は更新頻度を requestAnimationFrame でスロットリング + const [pointer, setPointer] = useState({ x: 0, y: 0 }); + const pointerRef = useRef({ x: 0, y: 0 }); + const animationFrameRef = useRef(null); + + useEffect(() => { + // 親要素に対してのみイベントリスナーを追加 + const parent = ref.current?.parentElement; + if (!parent) return; + + const handleMouseMove = (event: MouseEvent) => { + pointerRef.current = { x: event.clientX, y: event.clientY }; + if (animationFrameRef.current === null) { + animationFrameRef.current = requestAnimationFrame(() => { + setPointer(pointerRef.current); + animationFrameRef.current = null; + }); + } + }; + + parent.addEventListener('mousemove', handleMouseMove); + return () => { + parent.removeEventListener('mousemove', handleMouseMove); + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + // 親要素の位置情報から相対的な位置を計算 const elementRect = ref.current?.parentElement?.getBoundingClientRect() ?? { left: 0, width: 0 }; const relativeX = pointer.x - elementRect.left; - const percentage = Math.max(0, Math.min(relativeX / elementRect.width, 1)); const pointedTime = duration * percentage; - // サムネイルが画面からはみ出ないようにサムネイル中央を基準として left を計算する + // サムネイルがはみ出さないように、中央基準で left を計算 const MIN_LEFT = SEEK_THUMBNAIL_WIDTH / 2; const MAX_LEFT = elementRect.width - SEEK_THUMBNAIL_WIDTH / 2; diff --git a/workspaces/client/src/pages/episode/hooks/useCurrentTime.ts b/workspaces/client/src/pages/episode/hooks/useCurrentTime.ts index 4012382d0..c26c43df1 100644 --- a/workspaces/client/src/pages/episode/hooks/useCurrentTime.ts +++ b/workspaces/client/src/pages/episode/hooks/useCurrentTime.ts @@ -1,9 +1,14 @@ +import { useCallback } from 'react'; + import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useCurrentTime() { - const state = useStore((s) => s); - const update = (second: number): void => { - state.pages.episode.updateCurrentTime(second); - }; - return [state.pages.episode.currentTime, update] as const; + const currentTime = useStore((s) => s.pages.episode.currentTime); + const updateCurrentTime = useStore((s) => s.pages.episode.updateCurrentTime); + + const update = useCallback((second: number): void => { + updateCurrentTime(second); + }, [updateCurrentTime]); + + return [currentTime, update] as const; } diff --git a/workspaces/client/src/pages/episode/hooks/useDuration.ts b/workspaces/client/src/pages/episode/hooks/useDuration.ts index d54b086fc..2e47be0d5 100644 --- a/workspaces/client/src/pages/episode/hooks/useDuration.ts +++ b/workspaces/client/src/pages/episode/hooks/useDuration.ts @@ -1,6 +1,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useDuration() { - const state = useStore((s) => s); - return state.pages.episode.duration; + return useStore((s) => s.pages.episode.duration); } diff --git a/workspaces/client/src/pages/episode/hooks/useMuted.ts b/workspaces/client/src/pages/episode/hooks/useMuted.ts index 706214ee5..702fb2480 100644 --- a/workspaces/client/src/pages/episode/hooks/useMuted.ts +++ b/workspaces/client/src/pages/episode/hooks/useMuted.ts @@ -1,10 +1,14 @@ +import { useCallback } from 'react'; + import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useMuted() { - const state = useStore((s) => s); - const muted = state.pages.episode.muted; - const toggleMuted = () => { - state.pages.episode.setMuted(!muted); - }; + const muted = useStore((s) => s.pages.episode.muted); + const setMuted = useStore((s) => s.pages.episode.setMuted); + + const toggleMuted = useCallback(() => { + setMuted(!muted); + }, [muted, setMuted]); + return [muted, toggleMuted] as const; } diff --git a/workspaces/client/src/pages/episode/hooks/usePlayerRef.ts b/workspaces/client/src/pages/episode/hooks/usePlayerRef.ts index 54cb75f39..8c5375d43 100644 --- a/workspaces/client/src/pages/episode/hooks/usePlayerRef.ts +++ b/workspaces/client/src/pages/episode/hooks/usePlayerRef.ts @@ -1,6 +1,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function usePlayerRef() { - const state = useStore((s) => s); - return state.pages.episode.playerRef; + return useStore((s) => s.pages.episode.playerRef); } diff --git a/workspaces/client/src/pages/episode/hooks/usePlaying.ts b/workspaces/client/src/pages/episode/hooks/usePlaying.ts index 650ff929f..7b8348c71 100644 --- a/workspaces/client/src/pages/episode/hooks/usePlaying.ts +++ b/workspaces/client/src/pages/episode/hooks/usePlaying.ts @@ -1,13 +1,19 @@ +import { useCallback } from 'react'; + import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function usePlaying() { - const state = useStore((s) => s); - const toggle = (): void => { - if (state.pages.episode.playing) { - state.pages.episode.pause(); + const playing = useStore((s) => s.pages.episode.playing); + const play = useStore((s) => s.pages.episode.play); + const pause = useStore((s) => s.pages.episode.pause); + + const toggle = useCallback((): void => { + if (playing) { + pause(); } else { - state.pages.episode.play(); + play(); } - }; - return [state.pages.episode.playing, toggle] as const; + }, [playing, play, pause]); + + return [playing, toggle] as const; } diff --git a/workspaces/client/src/pages/program/components/ProgramPage.tsx b/workspaces/client/src/pages/program/components/ProgramPage.tsx index d13dc9426..4c9bac5ec 100644 --- a/workspaces/client/src/pages/program/components/ProgramPage.tsx +++ b/workspaces/client/src/pages/program/components/ProgramPage.tsx @@ -41,24 +41,20 @@ export const ProgramPage = () => { invariant(program); const timetable = useTimetable(); - const nextProgram = timetable[program.channel.id]?.find((p) => { - return DateTime.fromISO(program.endAt).equals(DateTime.fromISO(p.startAt)); - }); + const nextProgram = timetable[program.channel.id]?.find((p) => + DateTime.fromISO(program.endAt).equals(DateTime.fromISO(p.startAt)), + ); const modules = useRecommended({ referenceId: programId }); - const playerRef = usePlayerRef(); - const forceUpdate = useUpdate(); const navigate = useNavigate(); const isArchivedRef = useRef(DateTime.fromISO(program.endAt) <= DateTime.now()); const isBroadcastStarted = DateTime.fromISO(program.startAt) <= DateTime.now(); + useEffect(() => { - if (isArchivedRef.current) { - return; - } + if (isArchivedRef.current) return; - // 放送前であれば、放送開始になるまで画面を更新し続ける if (!isBroadcastStarted) { let timeout = setTimeout(function tick() { forceUpdate(); @@ -69,13 +65,11 @@ export const ProgramPage = () => { }; } - // 放送中に次の番組が始まったら、画面をそのままにしつつ、情報を次の番組にする let timeout = setTimeout(function tick() { if (DateTime.now() < DateTime.fromISO(program.endAt)) { timeout = setTimeout(tick, 250); return; } - if (nextProgram?.id) { void navigate(`/programs/${nextProgram.id}`, { preventScrollReset: true, @@ -95,14 +89,12 @@ export const ProgramPage = () => { return ( <> {`${program.title} - ${program.episode.series.title} - AremaTV`} -
{isArchivedRef.current ? (
-

この番組は放送が終了しました

{ ) : (
-

この番組は {DateTime.fromISO(program.startAt).toFormat('L月d日 H:mm')} に放送予定です @@ -138,7 +129,6 @@ export const ProgramPage = () => { )}

-
@@ -155,13 +145,11 @@ export const ProgramPage = () => {
- - {modules[0] != null ? ( + {modules[0] != null && (
- ) : null} - + )}

関連するエピソード

diff --git a/workspaces/client/src/pages/series/components/SeriesPage.tsx b/workspaces/client/src/pages/series/components/SeriesPage.tsx index abf35aee5..dc923e7ca 100644 --- a/workspaces/client/src/pages/series/components/SeriesPage.tsx +++ b/workspaces/client/src/pages/series/components/SeriesPage.tsx @@ -30,7 +30,6 @@ export const SeriesPage = () => { return ( <> {`${series.title} - AremaTV`} -
@@ -49,17 +48,15 @@ export const SeriesPage = () => {
-

エピソード

- - {modules[0] != null ? ( + {modules[0] != null && (
- ) : null} + )}
); diff --git a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx index 41e0ec14c..5b2787bd8 100644 --- a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx +++ b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx @@ -25,7 +25,6 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement =>

番組詳細

-

{program.title}

{program.description}
@@ -35,11 +34,9 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => className="mb-[24px] w-full rounded-[8px] border-[2px] border-solid border-[#FFFFFF1F]" src={program.thumbnailUrl} /> - - {episode != null ? ( + {episode != null && ( <>

番組で放送するエピソード

-

{episode.title}

{episode.description}
@@ -50,8 +47,7 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => src={episode.thumbnailUrl} /> - ) : null} - + )}
s); - return state.pages.timetable.changeColumnWidth; + return useStore((s) => s.pages.timetable.changeColumnWidth); } diff --git a/workspaces/client/src/pages/timetable/hooks/useCloseNewFeatureDialog.ts b/workspaces/client/src/pages/timetable/hooks/useCloseNewFeatureDialog.ts index 877fa65ef..10be5bd86 100644 --- a/workspaces/client/src/pages/timetable/hooks/useCloseNewFeatureDialog.ts +++ b/workspaces/client/src/pages/timetable/hooks/useCloseNewFeatureDialog.ts @@ -1,6 +1,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useCloseNewFeatureDialog() { - const state = useStore((s) => s); - return state.pages.timetable.closeNewFeatureDialog; + return useStore((s) => s.pages.timetable.closeNewFeatureDialog); } diff --git a/workspaces/client/src/pages/timetable/hooks/useColumnWidth.ts b/workspaces/client/src/pages/timetable/hooks/useColumnWidth.ts index fb58f6a03..b548ff035 100644 --- a/workspaces/client/src/pages/timetable/hooks/useColumnWidth.ts +++ b/workspaces/client/src/pages/timetable/hooks/useColumnWidth.ts @@ -2,6 +2,5 @@ import { useStore } from '@wsh-2025/client/src/app/StoreContext'; import { DEFAULT_WIDTH } from '@wsh-2025/client/src/features/timetable/constants/grid_size'; export function useColumnWidth(channelId: string): number { - const state = useStore((s) => s); - return state.pages.timetable.columnWidthRecord[channelId] ?? DEFAULT_WIDTH; + return useStore((s) => s.pages.timetable.columnWidthRecord[channelId] ?? DEFAULT_WIDTH); } diff --git a/workspaces/client/src/pages/timetable/hooks/useCurrentUnixtimeMs.ts b/workspaces/client/src/pages/timetable/hooks/useCurrentUnixtimeMs.ts index b54997cd6..28da23e71 100644 --- a/workspaces/client/src/pages/timetable/hooks/useCurrentUnixtimeMs.ts +++ b/workspaces/client/src/pages/timetable/hooks/useCurrentUnixtimeMs.ts @@ -3,14 +3,18 @@ import { useEffect } from 'react'; import { useStore } from '@wsh-2025/client/src/app/StoreContext'; export function useCurrentUnixtimeMs(): number { - const state = useStore((s) => s); + const currentUnixtimeMs = useStore((s) => s.pages.timetable.currentUnixtimeMs); + const refreshCurrentUnixtimeMs = useStore((s) => s.pages.timetable.refreshCurrentUnixtimeMs); + useEffect(() => { const interval = setInterval(() => { - state.pages.timetable.refreshCurrentUnixtimeMs(); + refreshCurrentUnixtimeMs(); }, 250); + return () => { clearInterval(interval); }; - }, []); - return state.pages.timetable.currentUnixtimeMs; + }, [refreshCurrentUnixtimeMs]); + + return currentUnixtimeMs; } diff --git a/workspaces/server/src/index.ts b/workspaces/server/src/index.ts index e02ab7613..555984a8e 100644 --- a/workspaces/server/src/index.ts +++ b/workspaces/server/src/index.ts @@ -13,9 +13,14 @@ async function main() { const app = fastify(); - app.addHook('onSend', async (_req, reply) => { - reply.header('cache-control', 'no-store'); + // グローバルな onSend フックでキャッシュヘッダー未設定の場合のみ no-store を適用 + app.addHook('onSend', async (_req, reply, payload) => { + if (!reply.getHeader('cache-control')) { + reply.header('cache-control', 'no-store'); + } + return payload; }); + app.register(cors, { origin: true, }); diff --git a/workspaces/server/src/ssr.tsx b/workspaces/server/src/ssr.tsx index 7603aafdd..30f8adce0 100644 --- a/workspaces/server/src/ssr.tsx +++ b/workspaces/server/src/ssr.tsx @@ -1,4 +1,3 @@ -import { readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,18 +12,6 @@ import { StrictMode } from 'react'; import { renderToString } from 'react-dom/server'; import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router'; -function getFiles(parent: string): string[] { - const dirents = readdirSync(parent, { withFileTypes: true }); - return dirents - .filter((dirent) => dirent.isFile() && !dirent.name.startsWith('.')) - .map((dirent) => path.join(parent, dirent.name)); -} - -function getFilePaths(relativePath: string, rootDir: string): string[] { - const files = getFiles(path.resolve(rootDir, relativePath)); - return files.map((file) => path.join('/', path.relative(rootDir, file))); -} - export function registerSsr(app: FastifyInstance): void { app.register(fastifyStatic, { prefix: '/public/', @@ -32,6 +19,10 @@ export function registerSsr(app: FastifyInstance): void { path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../client/dist'), path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../public'), ], + // 静的ファイルのみ長期キャッシュ(1年・immutable) + setHeaders: (res, _pathName, _stat) => { + res.setHeader('cache-control', 'public, max-age=31536000, immutable'); + }, }); app.get('/favicon.ico', (_, reply) => { @@ -59,13 +50,6 @@ export function registerSsr(app: FastifyInstance): void { , ); - const rootDir = path.resolve(__dirname, '../../../'); - const imagePaths = [ - getFilePaths('public/images', rootDir), - getFilePaths('public/animations', rootDir), - getFilePaths('public/logos', rootDir), - ].flat(); - reply.type('text/html').send(/* html */ ` @@ -73,7 +57,6 @@ export function registerSsr(app: FastifyInstance): void { - ${imagePaths.map((imagePath) => ``).join('\n')} diff --git a/workspaces/server/src/streams.tsx b/workspaces/server/src/streams.tsx index 0c45fcf6d..0f4211eec 100644 --- a/workspaces/server/src/streams.tsx +++ b/workspaces/server/src/streams.tsx @@ -21,6 +21,10 @@ export function registerStreams(app: FastifyInstance): void { app.register(fastifyStatic, { prefix: '/streams/', root: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../streams'), + // 静的ファイルのみ長期キャッシュ(1年・immutable) + setHeaders: (res, _pathName, _stat) => { + res.setHeader('cache-control', 'public, max-age=31536000, immutable'); + }, }); app.get<{