コミットを比較
5 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 4bf26c3ead | |||
| 5657fa7e7b | |||
| 77294e8caa | |||
| 0d6bf0d12b | |||
| 19a185d5b5 |
+3
-1
@@ -6,6 +6,7 @@ import { BrowserRouter,
|
||||
Routes,
|
||||
useLocation } from 'react-router-dom'
|
||||
|
||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||
import TopNav from '@/components/TopNav'
|
||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||
@@ -145,6 +146,7 @@ const App: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<RouteBlockerOverlay/>
|
||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||
|
||||
<BrowserRouter>
|
||||
<DialogueProvider>
|
||||
@@ -152,7 +154,7 @@ const App: FC = () => {
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const ROW_COUNT = 8
|
||||
const COLUMN_COUNT = 12
|
||||
|
||||
|
||||
const DevModeWatermark: FC = () => {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
|
||||
<style>{`
|
||||
@font-face {
|
||||
font-family: 'Nikumaru';
|
||||
src: url(${nikumaru}) format('opentype');
|
||||
}
|
||||
`}</style>
|
||||
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
|
||||
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className={
|
||||
'flex min-h-32 items-center gap-12 '
|
||||
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
|
||||
}>
|
||||
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
||||
<span
|
||||
key={columnIndex}
|
||||
className={
|
||||
'whitespace-nowrap text-3xl font-bold '
|
||||
+ 'tracking-[0.3em] text-neutral-950/5 '
|
||||
+ 'dark:text-white/10'
|
||||
}
|
||||
style={{ fontFamily: 'Nikumaru' }}>
|
||||
開発モード
|
||||
</span>))}
|
||||
</div>))}
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
export default DevModeWatermark
|
||||
@@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
||||
<TagLink
|
||||
tag={sidebarTagToTag (tag)}
|
||||
title={tag.name}
|
||||
truncateOnMobile
|
||||
withCount={false}
|
||||
withWiki={false}
|
||||
to={materialPath (tag.id, materialFilter)}
|
||||
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
||||
[max-inline-size:var(--tag-link-inline-size)]
|
||||
[max-height:var(--tag-link-inline-size)]"/>
|
||||
[max-height:var(--tag-link-inline-size)]
|
||||
[&_.tag-marquee]:block
|
||||
[&_.tag-marquee]:h-full
|
||||
[&_.tag-marquee]:overflow-hidden
|
||||
[&_.tag-marquee__static]:h-full
|
||||
[&_.tag-marquee__static]:overflow-hidden
|
||||
[&_.tag-marquee__static]:text-ellipsis
|
||||
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
|
||||
</div>
|
||||
{tag.hasChildren && (
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { FC } from 'react'
|
||||
type Props = {
|
||||
text: string
|
||||
className?: string
|
||||
truncateOnMobile?: boolean
|
||||
title?: string }
|
||||
|
||||
const DESKTOP_MARQUEE_MEDIA =
|
||||
@@ -16,7 +17,9 @@ const MARQUEE_SCROLL_PX_PER_SECOND = 43
|
||||
const MIN_MARQUEE_OVERFLOW_PX = 1
|
||||
|
||||
|
||||
const ResponsiveMarqueeText: FC<Props> = ({ text, className, title }) => {
|
||||
const ResponsiveMarqueeText: FC<Props> = (
|
||||
{ text, className, truncateOnMobile = false, title },
|
||||
) => {
|
||||
const outerRef = useRef<HTMLSpanElement | null> (null)
|
||||
const staticRef = useRef<HTMLSpanElement | null> (null)
|
||||
const animatedRef = useRef<HTMLSpanElement | null> (null)
|
||||
@@ -216,14 +219,18 @@ const ResponsiveMarqueeText: FC<Props> = ({ text, className, title }) => {
|
||||
onBlur={() => setActive (false)}
|
||||
className={cn (
|
||||
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
|
||||
'whitespace-normal [overflow-wrap:anywhere]',
|
||||
(truncateOnMobile
|
||||
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
||||
'md:overflow-hidden md:whitespace-nowrap',
|
||||
className)}>
|
||||
<span
|
||||
ref={staticRef}
|
||||
className={cn (
|
||||
'tag-marquee__static block max-w-full',
|
||||
'whitespace-normal [overflow-wrap:anywhere]',
|
||||
(truncateOnMobile
|
||||
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
||||
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
|
||||
marqueeVisible && 'md:opacity-0')}>
|
||||
{text}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Tag } from '@/types'
|
||||
type CommonProps = {
|
||||
tag: Tag
|
||||
nestLevel?: number
|
||||
truncateOnMobile?: boolean
|
||||
withWiki?: boolean
|
||||
withCount?: boolean }
|
||||
|
||||
@@ -31,6 +32,7 @@ type Props =
|
||||
const TagLink: FC<Props> = ({ tag,
|
||||
nestLevel = 0,
|
||||
linkFlg = true,
|
||||
truncateOnMobile = false,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
className,
|
||||
@@ -119,7 +121,10 @@ const TagLink: FC<Props> = ({ tag,
|
||||
title={textTitle}
|
||||
className={cn (spanClass, textClass, className)}
|
||||
{...props}>
|
||||
<ResponsiveMarqueeText text={tag.matchedAlias} title={textTitle}/>
|
||||
<ResponsiveMarqueeText
|
||||
text={tag.matchedAlias}
|
||||
title={textTitle}
|
||||
truncateOnMobile={truncateOnMobile}/>
|
||||
</span>
|
||||
<span className="shrink-0"> → </span>
|
||||
</>)}
|
||||
@@ -130,14 +135,20 @@ const TagLink: FC<Props> = ({ tag,
|
||||
title={textTitle}
|
||||
className={cn (linkClass, textClass, className)}
|
||||
{...props}>
|
||||
<ResponsiveMarqueeText text={tag.name} title={textTitle}/>
|
||||
<ResponsiveMarqueeText
|
||||
text={tag.name}
|
||||
title={textTitle}
|
||||
truncateOnMobile={truncateOnMobile}/>
|
||||
</PrefetchLink>)
|
||||
: (
|
||||
<span
|
||||
title={textTitle}
|
||||
className={cn (spanClass, textClass, className)}
|
||||
{...props}>
|
||||
<ResponsiveMarqueeText text={tag.name} title={textTitle}/>
|
||||
<ResponsiveMarqueeText
|
||||
text={tag.name}
|
||||
title={textTitle}
|
||||
truncateOnMobile={truncateOnMobile}/>
|
||||
</span>)}
|
||||
{withCount && (
|
||||
<span className={countClass}>{tag.postCount}</span>)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
||||
import { buildPost,
|
||||
buildTag,
|
||||
buildTheatre,
|
||||
buildTheatreComment,
|
||||
buildTheatreInfo,
|
||||
@@ -96,6 +97,9 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
|
||||
{ route: '/theatres/7' },
|
||||
)
|
||||
|
||||
const tagSection = (): HTMLElement =>
|
||||
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
|
||||
|
||||
const mockDefaultApi = () => {
|
||||
api.apiGet.mockImplementation ((path: string) => {
|
||||
switch (path)
|
||||
@@ -245,6 +249,53 @@ describe ('TheatreDetailPage', () => {
|
||||
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
||||
})
|
||||
|
||||
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
|
||||
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
||||
const parentTag = buildTag ({
|
||||
id: 11,
|
||||
name: '親タグ',
|
||||
category: 'general',
|
||||
children: [childTag],
|
||||
})
|
||||
const duplicateParentTag = buildTag ({
|
||||
id: 13,
|
||||
name: '別親タグ',
|
||||
category: 'general',
|
||||
children: [childTag],
|
||||
})
|
||||
|
||||
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
|
||||
...currentPost,
|
||||
tags: [parentTag, duplicateParentTag],
|
||||
}))
|
||||
|
||||
renderPage ()
|
||||
|
||||
await screen.findByText ('Embed:上映中の投稿')
|
||||
|
||||
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
|
||||
.toBeInTheDocument ()
|
||||
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
|
||||
|
||||
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
|
||||
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
|
||||
|
||||
await waitFor (() => {
|
||||
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
|
||||
.toBeInTheDocument ()
|
||||
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
|
||||
.toHaveLength (1)
|
||||
})
|
||||
})
|
||||
|
||||
it ('does not advance host post while video length is unknown', async () => {
|
||||
api.apiPut.mockImplementation ((path: string) => {
|
||||
switch (path)
|
||||
|
||||
@@ -102,6 +102,28 @@ const compareTagName = (a: Tag, b: Tag): number =>
|
||||
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
||||
|
||||
|
||||
const flattenTags = (tags: Tag[]): Tag[] => {
|
||||
const flattened: Tag[] = []
|
||||
const seen = new Set<number> ()
|
||||
|
||||
const visit = (tag: Tag) => {
|
||||
if (seen.has (tag.id))
|
||||
return
|
||||
|
||||
seen.add (tag.id)
|
||||
flattened.push (tag)
|
||||
|
||||
for (const child of tag.children ?? [])
|
||||
visit (child)
|
||||
}
|
||||
|
||||
for (const tag of tags)
|
||||
visit (tag)
|
||||
|
||||
return flattened
|
||||
}
|
||||
|
||||
|
||||
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
||||
const grouped: Partial<Record<Category, Tag[]>> = { }
|
||||
|
||||
@@ -118,15 +140,39 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
||||
}
|
||||
|
||||
|
||||
const renderReadonlyTagTree = (
|
||||
tag: Tag,
|
||||
nestLevel: number,
|
||||
path: string,
|
||||
ancestors: Set<number> = new Set<number> (),
|
||||
): ReactNode[] => {
|
||||
const key = `${ path }-${ tag.id }`
|
||||
const nextAncestors = new Set (ancestors)
|
||||
nextAncestors.add (tag.id)
|
||||
|
||||
return [
|
||||
(
|
||||
<li key={key} className="text-left leading-tight">
|
||||
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
|
||||
</li>),
|
||||
...((tag.children ?? [])
|
||||
.filter (child => !(nextAncestors.has (child.id)))
|
||||
.sort (compareTagName)
|
||||
.flatMap (child =>
|
||||
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
|
||||
}
|
||||
|
||||
|
||||
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||
{ tags, compact, flow = 'vertical' }) => {
|
||||
const grouped = tagsByCategory (tags)
|
||||
const horizontalGrouped = tagsByCategory (flattenTags (tags))
|
||||
const verticalGrouped = tagsByCategory (tags)
|
||||
|
||||
if (flow === 'horizontal')
|
||||
{
|
||||
return (
|
||||
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
||||
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
|
||||
{CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
|
||||
<li key={tag.id} className="text-left leading-tight">
|
||||
<TagLink tag={tag} withCount={false}/>
|
||||
</li>))}
|
||||
@@ -136,7 +182,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{CATEGORIES.map (cat => {
|
||||
const rows = grouped[cat] ?? []
|
||||
const rows = verticalGrouped[cat] ?? []
|
||||
if (rows.length === 0)
|
||||
return null
|
||||
|
||||
@@ -146,10 +192,8 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||
{CATEGORY_NAMES[cat]}
|
||||
</div>
|
||||
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
||||
{rows.map (tag => (
|
||||
<li key={tag.id} className="text-left leading-tight">
|
||||
<TagLink tag={tag} withCount={false}/>
|
||||
</li>))}
|
||||
{rows.flatMap (tag =>
|
||||
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
|
||||
</ul>
|
||||
</div>)
|
||||
})}
|
||||
|
||||
新しい課題から参照
ユーザをブロックする