Browse Source

Merge remote-tracking branch 'origin/main' into feature/140

pull/254/head
みてるぞ 1 week ago
parent
commit
874559dc6c
12 changed files with 215 additions and 86 deletions
  1. +8
    -8
      frontend/package-lock.json
  2. +1
    -1
      frontend/package.json
  3. +38
    -19
      frontend/src/App.tsx
  4. +60
    -16
      frontend/src/components/PostList.tsx
  5. +32
    -14
      frontend/src/components/PrefetchLink.tsx
  6. +7
    -3
      frontend/src/components/TopNav.tsx
  7. +9
    -5
      frontend/src/components/layout/MainArea.tsx
  8. +1
    -2
      frontend/src/lib/api.ts
  9. +1
    -1
      frontend/src/main.tsx
  10. +20
    -3
      frontend/src/pages/posts/PostDetailPage.tsx
  11. +19
    -14
      frontend/src/pages/posts/PostListPage.tsx
  12. +19
    -0
      frontend/src/stores/sharedTransitionStore.ts

+ 8
- 8
frontend/package-lock.json View File

@@ -31,7 +31,7 @@
"react-helmet-async": "^2.0.5",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.30.1",
"react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0",
@@ -6030,9 +6030,9 @@
}
},
"node_modules/react-router": {
"version": "6.30.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
"integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.0"
@@ -6045,13 +6045,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz",
"integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.0",
"react-router": "6.30.0"
"react-router": "6.30.1"
},
"engines": {
"node": ">=14.0.0"


+ 1
- 1
frontend/package.json View File

@@ -33,7 +33,7 @@
"react-helmet-async": "^2.0.5",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.0",
"react-router-dom": "^6.30.1",
"react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0",


+ 38
- 19
frontend/src/App.tsx View File

@@ -1,5 +1,10 @@
import { AnimatePresence, LayoutGroup } from 'framer-motion'
import { useEffect, useState } from 'react'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { BrowserRouter,
Navigate,
Route,
Routes,
useLocation } from 'react-router-dom'

import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
@@ -20,11 +25,41 @@ import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'

import type { FC } from 'react'
import type { Dispatch, FC, SetStateAction } from 'react'

import type { User } from '@/types'


const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation ()

return (
<LayoutGroup id="gallery-shared">
<AnimatePresence mode="wait">
<Routes location={location} key={location.pathname}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
</AnimatePresence>
</LayoutGroup>)
}


const PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation ()
const key = location.pathname
@@ -81,23 +116,7 @@ export default (() => {
<BrowserRouter>
<div className="flex flex-col h-screen w-screen">
<TopNav user={user}/>
<Routes>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</div>
<Toaster/>
</BrowserRouter>


+ 60
- 16
frontend/src/components/PostList.tsx View File

@@ -1,4 +1,9 @@
import { motion } from 'framer-motion'
import { useRef } from 'react'
import { useLocation } from 'react-router-dom'

import PrefetchLink from '@/components/PrefetchLink'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'

import type { FC, MouseEvent } from 'react'

@@ -8,19 +13,58 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void }


export default (({ posts, onClick }: Props) => (
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
onClick={onClick}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
</PrefetchLink>))}
</div>)) satisfies FC<Props>
export default (({ posts, onClick }: Props) => {
const location = useLocation ()

const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)

const cardRef = useRef<HTMLDivElement> (null)

return (
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => {
const sharedId = `page-${ post.id }`
const layoutId = sharedId

return (
<PrefetchLink
to={`/posts/${ post.id }`}
key={post.id}
className="w-40 h-40"
state={{ sharedId }}
onClick={e => {
setForLocationKey (location.key, sharedId)
onClick?.(e)
}}>
<motion.div
ref={cardRef}
layoutId={layoutId}
className="w-full h-full overflow-hidden rounded-xl shadow
transform-gpu will-change-transform"
whileHover={{ scale: 1.02 }}
onLayoutAnimationStart={() => {
if (!(cardRef.current))
return

cardRef.current.style.position = 'relative'
cardRef.current.style.zIndex = '9999'
}}
onLayoutAnimationComplete={() => {
if (!(cardRef.current))
return

cardRef.current.style.zIndex = ''
cardRef.current.style.position = ''
}}
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
<img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url}
title={post.title || post.url || undefined}
loading={i < 12 ? 'eager' : 'lazy'}
decoding="async"
className="object-cover w-full h-full"/>
</motion.div>
</PrefetchLink>)
})}
</div>)
}) satisfies FC<Props>

+ 32
- 14
frontend/src/components/PrefetchLink.tsx View File

@@ -1,5 +1,6 @@
import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { createPath, useNavigate } from 'react-router-dom'

import { useOverlayStore } from '@/components/RouteBlockerOverlay'
@@ -11,6 +12,7 @@ import type { To } from 'react-router-dom'

type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
to: To
state?: Record<string, string>
replace?: boolean
className?: string
cancelOnError?: boolean }
@@ -20,11 +22,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
to,
replace,
className,
state,
onMouseEnter,
onTouchStart,
onClick,
cancelOnError = false,
...rest }, ref) => {
if ('onClick' in rest)
delete rest['onClick']

const navigate = useNavigate ()
const qc = useQueryClient ()
const url = useMemo (() => {
@@ -57,25 +63,37 @@ export default forwardRef<HTMLAnchorElement, Props> (({
}

const handleClick = async (ev: MouseEvent<HTMLAnchorElement>) => {
onClick?.(ev)
try
{
onClick?.(ev)

if (ev.defaultPrevented
|| ev.metaKey
|| ev.ctrlKey
|| ev.shiftKey
|| ev.altKey)
return
if (ev.defaultPrevented
|| ev.metaKey
|| ev.ctrlKey
|| ev.shiftKey
|| ev.altKey)
return

ev.preventDefault ()
ev.preventDefault ()

setOverlay (true)
const ok = await doPrefetch ()
setOverlay (false)
flushSync (() => {
setOverlay (true)
})
const ok = await doPrefetch ()
flushSync (() => {
setOverlay (false)
})

if (!(ok) && cancelOnError)
return
if (!(ok) && cancelOnError)
return

navigate (to, { replace })
navigate (to, { replace, ...(state && { state }) })
}
catch (ex)
{
console.log (ex)
ev.preventDefault ()
}
}

return (


+ 7
- 3
frontend/src/components/TopNav.tsx View File

@@ -138,9 +138,13 @@ export default (({ user }: Props) => {
<nav className="px-3 flex justify-between items-center w-full min-h-[48px]
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div className="flex items-center gap-2 h-full">
<PrefetchLink to="/"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100">
<PrefetchLink
to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={() => {
scroll (0, 0)
}}>
ぼざクリ タグ広場
</PrefetchLink>



+ 9
- 5
frontend/src/components/layout/MainArea.tsx View File

@@ -1,9 +1,13 @@
import React from 'react'
import { cn } from '@/lib/utils'

type Props = { children: React.ReactNode }
import type { FC, ReactNode } from 'react'

type Props = {
children: ReactNode
className?: string }

export default ({ children }: Props) => (
<main className="flex-1 overflow-y-auto p-4">

export default (({ children, className }: Props) => (
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
{children}
</main>)
</main>)) satisfies FC<Props>

+ 1
- 2
frontend/src/lib/api.ts View File

@@ -72,5 +72,4 @@ export const apiDelete = async (
}


export const isApiError = (err: unknown): err is AxiosError =>
axios.isAxiosError (err)
export const isApiError = (err: unknown): err is AxiosError => axios.isAxiosError (err)

+ 1
- 1
frontend/src/main.tsx View File

@@ -11,7 +11,7 @@ const client = new QueryClient ({
defaultOptions: {
queries: { staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
retry: 1 }}})
retry: 1 } } })

createRoot (document.getElementById ('root')!).render (
<HelmetProvider context={helmetContext}>


+ 20
- 3
frontend/src/pages/posts/PostDetailPage.tsx View File

@@ -1,12 +1,13 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'

import PostList from '@/components/PostList'
import TagDetailSidebar from '@/components/TagDetailSidebar'
import PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed'
import PostList from '@/components/PostList'
import TagDetailSidebar from '@/components/TagDetailSidebar'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
@@ -72,6 +73,8 @@ export default (({ user }: Props) => {
}, [errorFlg, error])

useEffect (() => {
scroll (0, 0)

setStatus (200)
}, [id])

@@ -99,10 +102,24 @@ export default (({ user }: Props) => {
<TagDetailSidebar post={post ?? null}/>
</div>

<MainArea>
<MainArea className="relative">
{post
? (
<>
{(post.thumbnail || post.thumbnailBase) && (
<motion.div
layoutId={`page-${ id }`}
className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px]
overflow-hidden rounded-xl pointer-events-none z-50"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: .2, ease: 'easeOut' }}>
<img src={post.thumbnail || post.thumbnailBase}
alt={post.title || post.url}
title={post.title || post.url || undefined}
className="object-cover w-full h-full"/>
</motion.div>)}

<PostEmbed post={post}/>
<Button onClick={() => changeViewedFlg.mutate ()}
disabled={changeViewedFlg.isPending}


+ 19
- 14
frontend/src/pages/posts/PostListPage.tsx View File

@@ -15,10 +15,12 @@ import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { fetchWikiPageByTitle } from '@/lib/wiki'

import type { FC } from 'react'

import type { WikiPage } from '@/types'


export default () => {
export default (() => {
const containerRef = useRef<HTMLDivElement | null> (null)

const [wikiPage, setWikiPage] = useState<WikiPage | null> (null)
@@ -41,21 +43,24 @@ export default () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0

useLayoutEffect (() => {
scroll (0, 0)

setWikiPage (null)
if (tags.length === 1)

if (tags.length !== 1)
return

void (async () => {
try
{
const tagName = tags[0]
setWikiPage (await fetchWikiPageByTitle (tagName, { }))
}
catch
{
void (async () => {
try
{
const tagName = tags[0]
setWikiPage (await fetchWikiPageByTitle (tagName, { }))
}
catch
{
;
}
}) ()
;
}
}) ()
}, [location.search])

return (
@@ -100,4 +105,4 @@ export default () => {
</TabGroup>
</MainArea>
</div>)
}
}) satisfies FC

+ 19
- 0
frontend/src/stores/sharedTransitionStore.ts View File

@@ -0,0 +1,19 @@
import { create } from 'zustand'

type SharedTransitionState = {
byLocationKey: Record<string, string>
setForLocationKey: (locationKey: string, sharedId: string) => void
clearForLocationKey: (locationKey: string) => void }


export const useSharedTransitionStore = create<SharedTransitionState> (set => ({
byLocationKey: { },
setForLocationKey: (locationKey, sharedId) =>
set (state => ({ byLocationKey: { ...state.byLocationKey,
[locationKey]: sharedId } })),
clearForLocationKey: (locationKey) =>
set (state => {
const next = { ...state.byLocationKey }
delete next[locationKey]
return { byLocationKey: next }
}) }))

Loading…
Cancel
Save