| @@ -1,42 +0,0 @@ | |||||
| #root { | |||||
| max-width: 1280px; | |||||
| margin: 0 auto; | |||||
| padding: 2rem; | |||||
| text-align: center; | |||||
| } | |||||
| .logo { | |||||
| height: 6em; | |||||
| padding: 1.5em; | |||||
| will-change: filter; | |||||
| transition: filter 300ms; | |||||
| } | |||||
| .logo:hover { | |||||
| filter: drop-shadow(0 0 2em #646cffaa); | |||||
| } | |||||
| .logo.react:hover { | |||||
| filter: drop-shadow(0 0 2em #61dafbaa); | |||||
| } | |||||
| @keyframes logo-spin { | |||||
| from { | |||||
| transform: rotate(0deg); | |||||
| } | |||||
| to { | |||||
| transform: rotate(360deg); | |||||
| } | |||||
| } | |||||
| @media (prefers-reduced-motion: no-preference) { | |||||
| a:nth-of-type(2) .logo { | |||||
| animation: logo-spin infinite 20s linear; | |||||
| } | |||||
| } | |||||
| .card { | |||||
| padding: 2em; | |||||
| } | |||||
| .read-the-docs { | |||||
| color: #888; | |||||
| } | |||||
| @@ -1,6 +1,6 @@ | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Link } from 'react-router-dom' | |||||
| import TagLink from '@/components/TagLink' | |||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| @@ -14,47 +14,48 @@ type Props = { post: Post | null } | |||||
| export default ({ post }: Props) => { | export default ({ post }: Props) => { | ||||
| const [tags, setTags] = useState({ } as TagByCategory) | |||||
| const [tags, setTags] = useState ({ } as TagByCategory) | |||||
| const categoryNames: Partial<{ [key in Category]: string }> = { | |||||
| general: '一般', | |||||
| const categoryNames: Record<Category, string> = { | |||||
| deerjikist: 'ニジラー', | deerjikist: 'ニジラー', | ||||
| meme: '原作・ネタ元・ミーム等', | |||||
| character: 'キャラクター', | |||||
| general: '一般', | |||||
| material: '素材', | |||||
| meta: 'メタタグ', | |||||
| nico: 'ニコニコタグ' } | nico: 'ニコニコタグ' } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(post)) | if (!(post)) | ||||
| return | return | ||||
| const fetchTags = () => { | |||||
| const tagsTmp = { } as TagByCategory | |||||
| for (const tag of post.tags) | |||||
| { | |||||
| if (!(tag.category in tagsTmp)) | |||||
| tagsTmp[tag.category] = [] | |||||
| tagsTmp[tag.category].push (tag) | |||||
| } | |||||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||||
| setTags (tagsTmp) | |||||
| } | |||||
| fetchTags () | |||||
| const tagsTmp = { } as TagByCategory | |||||
| for (const tag of post.tags) | |||||
| { | |||||
| if (!(tag.category in tagsTmp)) | |||||
| tagsTmp[tag.category] = [] | |||||
| tagsTmp[tag.category].push (tag) | |||||
| } | |||||
| for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) | |||||
| tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1) | |||||
| setTags (tagsTmp) | |||||
| }, [post]) | }, [post]) | ||||
| return ( | return ( | ||||
| <SidebarComponent> | <SidebarComponent> | ||||
| <TagSearch /> | |||||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||||
| <div className="my-3" key={cat}> | |||||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||||
| <ul> | |||||
| {tags[cat].map (tag => ( | |||||
| <li key={tag.id} className="mb-1"> | |||||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}> | |||||
| {tag.name} | |||||
| </Link> | |||||
| </li>))} | |||||
| </ul> | |||||
| </div>))} | |||||
| <TagSearch /> | |||||
| {CATEGORIES.map ((cat: Category) => cat in tags && ( | |||||
| <div className="my-3" key={cat}> | |||||
| <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> | |||||
| <ul> | |||||
| {tags[cat].map ((tag, i) => ( | |||||
| <li key={i} className="mb-1"> | |||||
| <TagLink tag={tag} /> | |||||
| </li>))} | |||||
| </ul> | |||||
| </div>))} | |||||
| </SidebarComponent>) | </SidebarComponent>) | ||||
| } | } | ||||
| @@ -0,0 +1,48 @@ | |||||
| import { Link } from 'react-router-dom' | |||||
| import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | |||||
| import { cn } from '@/lib/utils' | |||||
| import type { Tag } from '@/types' | |||||
| type Props = { tag: Tag | |||||
| linkFlg?: boolean | |||||
| withWiki?: boolean | |||||
| withCount?: boolean } | |||||
| export default ({ tag, | |||||
| linkFlg = true, | |||||
| withWiki = true, | |||||
| withCount = true }: Props) => { | |||||
| const spanClass = cn ( | |||||
| `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, | |||||
| `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) | |||||
| const linkClass = cn ( | |||||
| spanClass, | |||||
| `hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`, | |||||
| `dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`) | |||||
| return ( | |||||
| <> | |||||
| {(linkFlg && withWiki) && ( | |||||
| <span className="mr-1"> | |||||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| className={linkClass}> | |||||
| ? | |||||
| </Link> | |||||
| </span>)} | |||||
| {linkFlg | |||||
| ? ( | |||||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||||
| className={linkClass}> | |||||
| {tag.name} | |||||
| </Link>) | |||||
| : ( | |||||
| <span className={spanClass}> | |||||
| {tag.name} | |||||
| </span>)} | |||||
| {withCount && ( | |||||
| <span className="ml-1">{tag.postCount}</span>)} | |||||
| </>) | |||||
| } | |||||
| @@ -1,7 +1,8 @@ | |||||
| import axios from 'axios' | import axios from 'axios' | ||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Link, useLocation, useNavigate } from 'react-router-dom' | |||||
| import { useLocation, useNavigate } from 'react-router-dom' | |||||
| import TagLink from '@/components/TagLink' | |||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| @@ -28,6 +29,7 @@ export default ({ posts }: Props) => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| const tagsTmp: TagByCategory = { } | const tagsTmp: TagByCategory = { } | ||||
| let cnt = 0 | let cnt = 0 | ||||
| loop: | loop: | ||||
| for (const post of posts) | for (const post of posts) | ||||
| { | { | ||||
| @@ -35,6 +37,7 @@ export default ({ posts }: Props) => { | |||||
| { | { | ||||
| if (!(tag.category in tagsTmp)) | if (!(tag.category in tagsTmp)) | ||||
| tagsTmp[tag.category] = [] | tagsTmp[tag.category] = [] | ||||
| if (!(tagsTmp[tag.category].map (t => t.id).includes (tag.id))) | if (!(tagsTmp[tag.category].map (t => t.id).includes (tag.id))) | ||||
| { | { | ||||
| tagsTmp[tag.category].push (tag) | tagsTmp[tag.category].push (tag) | ||||
| @@ -44,8 +47,10 @@ export default ({ posts }: Props) => { | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| for (const cat of Object.keys (tagsTmp)) | for (const cat of Object.keys (tagsTmp)) | ||||
| tagsTmp[cat].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) | tagsTmp[cat].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) | ||||
| setTags (tagsTmp) | setTags (tagsTmp) | ||||
| }, [posts]) | }, [posts]) | ||||
| @@ -57,10 +62,7 @@ export default ({ posts }: Props) => { | |||||
| {CATEGORIES.flatMap (cat => cat in tags ? ( | {CATEGORIES.flatMap (cat => cat in tags ? ( | ||||
| tags[cat].map (tag => ( | tags[cat].map (tag => ( | ||||
| <li key={tag.id} className="mb-1"> | <li key={tag.id} className="mb-1"> | ||||
| <Link to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}> | |||||
| {tag.name} | |||||
| </Link> | |||||
| <span className="ml-1">{tag.postCount}</span> | |||||
| <TagLink tag={tag} /> | |||||
| </li>))) : [])} | </li>))) : [])} | ||||
| </ul> | </ul> | ||||
| <SectionTitle>関聯</SectionTitle> | <SectionTitle>関聯</SectionTitle> | ||||
| @@ -17,7 +17,6 @@ const Menu = { None: 'None', | |||||
| User: 'User', | User: 'User', | ||||
| Tag: 'Tag', | Tag: 'Tag', | ||||
| Wiki: 'Wiki' } as const | Wiki: 'Wiki' } as const | ||||
| type Menu = typeof Menu[keyof typeof Menu] | type Menu = typeof Menu[keyof typeof Menu] | ||||
| @@ -38,9 +37,9 @@ export default ({ user }: Props) => { | |||||
| const MyLink = ({ to, title, base }: { to: string | const MyLink = ({ to, title, base }: { to: string | ||||
| title: string | title: string | ||||
| base?: string }) => ( | base?: string }) => ( | ||||
| <Link to={to} className={cn ('hover:text-orange-500 h-full flex items-center', | |||||
| <Link to={to} className={cn ('h-full flex items-center', | |||||
| (location.pathname.startsWith (base ?? to) | (location.pathname.startsWith (base ?? to) | ||||
| ? 'bg-gray-700 px-4 font-bold' | |||||
| ? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold' | |||||
| : 'px-2'))}> | : 'px-2'))}> | ||||
| {title} | {title} | ||||
| </Link>) | </Link>) | ||||
| @@ -121,11 +120,9 @@ export default ({ user }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| const pageRes = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`) | const pageRes = await axios.get (`${ API_BASE_URL }/wiki/${ wikiId }`) | ||||
| const pageData: any = pageRes.data | |||||
| const wikiPage = toCamel (pageData, { deep: true }) as WikiPage | |||||
| const wikiPage = toCamel (pageRes.data as any, { deep: true }) as WikiPage | |||||
| const tagRes = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`) | const tagRes = await axios.get (`${ API_BASE_URL }/tags/name/${ wikiPage.title }`) | ||||
| const tagData: any = tagRes.data | |||||
| const tag = toCamel (tagData, { deep: true }) as Tag | const tag = toCamel (tagData, { deep: true }) as Tag | ||||
| setPostCount (tag.postCount) | setPostCount (tag.postCount) | ||||
| @@ -139,25 +136,32 @@ export default ({ user }: Props) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <nav className="bg-gray-800 text-white px-3 flex justify-between items-center w-full min-h-[48px]"> | |||||
| <nav className="px-3 flex justify-between items-center w-full min-h-[48px] | |||||
| bg-yellow-50 dark:bg-red-975"> | |||||
| <div className="flex items-center gap-2 h-full"> | <div className="flex items-center gap-2 h-full"> | ||||
| <Link to="/" className="mx-4 text-xl font-bold text-orange-500">ぼざクリ タグ広場</Link> | |||||
| <Link to="/" className="mx-4 text-xl font-bold | |||||
| text-pink-600 hover:text-pink-400 | |||||
| dark:text-pink-300 dark:hover:text-pink-100"> | |||||
| ぼざクリ タグ広場 | |||||
| </Link> | |||||
| <MyLink to="/posts" title="広場" /> | <MyLink to="/posts" title="広場" /> | ||||
| <MyLink to="/tags" title="タグ" /> | <MyLink to="/tags" title="タグ" /> | ||||
| <MyLink to="/wiki/ヘルプ:ホーム" base="/wiki" title="Wiki" /> | <MyLink to="/wiki/ヘルプ:ホーム" base="/wiki" title="Wiki" /> | ||||
| <MyLink to="/users/settings" base="/users" title="ニジラー" /> | <MyLink to="/users/settings" base="/users" title="ニジラー" /> | ||||
| </div> | </div> | ||||
| <div className="ml-auto pr-4"> | |||||
| {user && ( | |||||
| <Button onClick={() => navigate ('/users/settings')} | |||||
| className="bg-gray-600"> | |||||
| {user && | |||||
| <div className="ml-auto pr-4"> | |||||
| <Link to="/users/settings" | |||||
| className="font-bold text-red-600 hover:text-red-400 | |||||
| dark:text-yellow-400 dark:hover:text-yellow-200"> | |||||
| {user.name || '名もなきニジラー'} | {user.name || '名もなきニジラー'} | ||||
| </Button>)} | |||||
| </div> | |||||
| </Link> | |||||
| </div>} | |||||
| </nav> | </nav> | ||||
| {(() => { | {(() => { | ||||
| const className = 'bg-gray-700 text-white px-3 flex items-center w-full min-h-[40px]' | |||||
| const subClass = 'hover:text-orange-500 h-full flex items-center px-3' | |||||
| const className = cn ('bg-yellow-200 dark:bg-red-950', | |||||
| 'text-white px-3 flex items-center w-full min-h-[40px]') | |||||
| const subClass = 'h-full flex items-center px-3' | |||||
| // const inputBox = 'flex items-center px-3 mx-2' | // const inputBox = 'flex items-center px-3 mx-2' | ||||
| const Separator = () => <span className="flex items-center px-2">|</span> | const Separator = () => <span className="flex items-center px-2">|</span> | ||||
| switch (selectedMenu) | switch (selectedMenu) | ||||
| @@ -1,11 +1,25 @@ | |||||
| export const CATEGORIES = ['general', | |||||
| 'character', | |||||
| 'deerjikist', | |||||
| import type { Category } from 'types' | |||||
| export const LIGHT_COLOUR_SHADE = 800 | |||||
| export const DARK_COLOUR_SHADE = 300 | |||||
| export const CATEGORIES = ['deerjikist', | |||||
| 'meme', | 'meme', | ||||
| 'character', | |||||
| 'general', | |||||
| 'material', | 'material', | ||||
| 'meta', | 'meta', | ||||
| 'nico'] as const | 'nico'] as const | ||||
| export const TAG_COLOUR = { | |||||
| deerjikist: 'rose', | |||||
| meme: 'purple', | |||||
| character: 'lime', | |||||
| general: 'cyan', | |||||
| material: 'orange', | |||||
| meta: 'yellow', | |||||
| nico: 'gray' } as const satisfies Record<Category, string> | |||||
| export const USER_ROLES = ['admin', 'member', 'guest'] as const | export const USER_ROLES = ['admin', 'member', 'guest'] as const | ||||
| export const ViewFlagBehavior = { OnShowedDetail: 1, | export const ViewFlagBehavior = { OnShowedDetail: 1, | ||||
| @@ -2,13 +2,25 @@ | |||||
| @tailwind components; | @tailwind components; | ||||
| @tailwind utilities; | @tailwind utilities; | ||||
| @layer base { | |||||
| body { | |||||
| @layer base | |||||
| { | |||||
| body | |||||
| { | |||||
| @apply overflow-x-clip; | @apply overflow-x-clip; | ||||
| } | } | ||||
| a | |||||
| { | |||||
| @apply text-blue-600 dark:text-blue-300; | |||||
| } | |||||
| a:hover | |||||
| { | |||||
| @apply text-blue-400 dark:text-blue-100; | |||||
| } | |||||
| } | } | ||||
| :root { | |||||
| :root | |||||
| { | |||||
| font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | ||||
| line-height: 1.5; | line-height: 1.5; | ||||
| font-weight: 400; | font-weight: 400; | ||||
| @@ -23,16 +35,14 @@ | |||||
| -moz-osx-font-smoothing: grayscale; | -moz-osx-font-smoothing: grayscale; | ||||
| } | } | ||||
| a { | |||||
| a | |||||
| { | |||||
| font-weight: 500; | font-weight: 500; | ||||
| color: #646cff; | |||||
| text-decoration: inherit; | text-decoration: inherit; | ||||
| } | } | ||||
| a:hover { | |||||
| color: #535bf2; | |||||
| } | |||||
| body { | |||||
| body | |||||
| { | |||||
| margin: 0; | margin: 0; | ||||
| display: flex; | display: flex; | ||||
| place-items: center; | place-items: center; | ||||
| @@ -40,12 +50,14 @@ body { | |||||
| min-height: 100vh; | min-height: 100vh; | ||||
| } | } | ||||
| h1 { | |||||
| h1 | |||||
| { | |||||
| font-size: 3.2em; | font-size: 3.2em; | ||||
| line-height: 1.1; | line-height: 1.1; | ||||
| } | } | ||||
| button { | |||||
| button | |||||
| { | |||||
| border-radius: 8px; | border-radius: 8px; | ||||
| border: 1px solid transparent; | border: 1px solid transparent; | ||||
| padding: 0.6em 1.2em; | padding: 0.6em 1.2em; | ||||
| @@ -56,23 +68,29 @@ button { | |||||
| cursor: pointer; | cursor: pointer; | ||||
| transition: border-color 0.25s; | transition: border-color 0.25s; | ||||
| } | } | ||||
| button:hover { | |||||
| button:hover | |||||
| { | |||||
| border-color: #646cff; | border-color: #646cff; | ||||
| } | } | ||||
| button:focus, | button:focus, | ||||
| button:focus-visible { | |||||
| button:focus-visible | |||||
| { | |||||
| outline: 4px auto -webkit-focus-ring-color; | outline: 4px auto -webkit-focus-ring-color; | ||||
| } | } | ||||
| @media (prefers-color-scheme: light) { | |||||
| :root { | |||||
| @media (prefers-color-scheme: light) | |||||
| { | |||||
| :root | |||||
| { | |||||
| color: #213547; | color: #213547; | ||||
| background-color: #ffffff; | background-color: #ffffff; | ||||
| } | } | ||||
| a:hover { | |||||
| a:hover | |||||
| { | |||||
| color: #747bff; | color: #747bff; | ||||
| } | } | ||||
| button { | |||||
| button | |||||
| { | |||||
| background-color: #f9f9f9; | background-color: #f9f9f9; | ||||
| } | } | ||||
| } | } | ||||
| @@ -51,7 +51,7 @@ export default ({ user }: Props) => { | |||||
| if (!(id)) | if (!(id)) | ||||
| return | return | ||||
| void (async () => { | |||||
| const fetchPost = async () => { | |||||
| try | try | ||||
| { | { | ||||
| const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: { | const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: { | ||||
| @@ -63,7 +63,8 @@ export default ({ user }: Props) => { | |||||
| if (axios.isAxiosError (err)) | if (axios.isAxiosError (err)) | ||||
| setStatus (err.status ?? 200) | setStatus (err.status ?? 200) | ||||
| } | } | ||||
| }) () | |||||
| } | |||||
| fetchPost () | |||||
| }, [id]) | }, [id]) | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -93,16 +93,16 @@ export default () => { | |||||
| <Tab name="広場"> | <Tab name="広場"> | ||||
| {posts.length | {posts.length | ||||
| ? ( | ? ( | ||||
| <div className="flex flex-wrap gap-4 gap-y-8 p-4 justify-between"> | |||||
| {posts.map (post => ( | |||||
| <Link to={`/posts/${ post.id }`} | |||||
| key={post.id} | |||||
| className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"> | |||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||||
| alt={post.title || post.url} | |||||
| title={post.title || post.url || undefined} | |||||
| className="object-none w-full h-full" /> | |||||
| </Link>))} | |||||
| <div className="flex flex-wrap gap-6 p-4"> | |||||
| {posts.map ((post, i) => ( | |||||
| <Link to={`/posts/${ post.id }`} | |||||
| key={i} | |||||
| className="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"> | |||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||||
| alt={post.title || post.url} | |||||
| title={post.title || post.url || undefined} | |||||
| className="object-none w-full h-full" /> | |||||
| </Link>))} | |||||
| </div>) | </div>) | ||||
| : !(loading) && '広場には何もありませんよ.'} | : !(loading) && '広場には何もありませんよ.'} | ||||
| {loading && 'Loading...'} | {loading && 'Loading...'} | ||||
| @@ -112,7 +112,7 @@ export default () => { | |||||
| <Tab name="Wiki" init={!(posts.length)}> | <Tab name="Wiki" init={!(posts.length)}> | ||||
| <WikiBody body={wikiPage.body} /> | <WikiBody body={wikiPage.body} /> | ||||
| <div className="my-2"> | <div className="my-2"> | ||||
| <Link to={`/wiki/${ wikiPage.title }`}>Wiki を見る</Link> | |||||
| <Link to={`/wiki/${ encodeURIComponent (wikiPage.title) }`}>Wiki を見る</Link> | |||||
| </div> | </div> | ||||
| </Tab>)} | </Tab>)} | ||||
| </TabGroup> | </TabGroup> | ||||
| @@ -3,6 +3,7 @@ import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useRef, useState } from 'react' | import { useEffect, useRef, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import TagLink from '@/components/TagLink' | |||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| @@ -15,10 +16,10 @@ type Props = { user: User | null } | |||||
| export default ({ user }: Props) => { | export default ({ user }: Props) => { | ||||
| const [nicoTags, setNicoTags] = useState<NicoTag[]> ([]) | |||||
| const [cursor, setCursor] = useState ('') | const [cursor, setCursor] = useState ('') | ||||
| const [loading, setLoading] = useState (false) | |||||
| const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | const [editing, setEditing] = useState<{ [key: number]: boolean }> ({ }) | ||||
| const [loading, setLoading] = useState (false) | |||||
| const [nicoTags, setNicoTags] = useState<NicoTag[]> ([]) | |||||
| const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ }) | const [rawTags, setRawTags] = useState<{ [key: number]: string }> ({ }) | ||||
| const loaderRef = useRef<HTMLDivElement | null> (null) | const loaderRef = useRef<HTMLDivElement | null> (null) | ||||
| @@ -56,6 +57,10 @@ export default ({ user }: Props) => { | |||||
| 'Content-Type': 'multipart/form-data', | 'Content-Type': 'multipart/form-data', | ||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | ||||
| const data = toCamel (res.data as any, { deep: true }) as Tag[] | const data = toCamel (res.data as any, { deep: true }) as Tag[] | ||||
| setNicoTags (nicoTags => { | |||||
| nicoTags.find (t => t.id === id).linkedTags = data | |||||
| return [...nicoTags] | |||||
| }) | |||||
| setRawTags (rawTags => ({ ...rawTags, [id]: data.map (t => t.name).join (' ') })) | setRawTags (rawTags => ({ ...rawTags, [id]: data.map (t => t.name).join (' ') })) | ||||
| toast ({ title: '更新しました.' }) | toast ({ title: '更新しました.' }) | ||||
| @@ -106,13 +111,10 @@ export default ({ user }: Props) => { | |||||
| </tr> | </tr> | ||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {nicoTags.map (tag => ( | |||||
| <tr key={tag.id}> | |||||
| {nicoTags.map ((tag, i) => ( | |||||
| <tr key={i}> | |||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <a href={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | |||||
| target="_blank"> | |||||
| {tag.name} | |||||
| </a> | |||||
| <TagLink tag={tag} withWiki={false} withCount={false} /> | |||||
| </td> | </td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| {editing[tag.id] | {editing[tag.id] | ||||
| @@ -120,7 +122,12 @@ export default ({ user }: Props) => { | |||||
| <TextArea value={rawTags[tag.id]} onChange={ev => { | <TextArea value={rawTags[tag.id]} onChange={ev => { | ||||
| setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) | setRawTags (rawTags => ({ ...rawTags, [tag.id]: ev.target.value })) | ||||
| }} />) | }} />) | ||||
| : rawTags[tag.id]} | |||||
| : tag.linkedTags.map((lt, j) => ( | |||||
| <span key={j} className="mr-2"> | |||||
| <TagLink tag={lt} | |||||
| linkFlg={false} | |||||
| withCount={false} /> | |||||
| </span>))} | |||||
| </td> | </td> | ||||
| {memberFlg && ( | {memberFlg && ( | ||||
| <td> | <td> | ||||
| @@ -2,9 +2,8 @@ import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | |||||
| export type Category = typeof CATEGORIES[number] | export type Category = typeof CATEGORIES[number] | ||||
| export type NicoTag = { | |||||
| id: number | |||||
| name: string | |||||
| export type NicoTag = Tag & { | |||||
| category: 'nico' | |||||
| linkedTags: Tag[] } | linkedTags: Tag[] } | ||||
| export type Post = { | export type Post = { | ||||
| @@ -1,20 +1,35 @@ | |||||
| /** @type {import('tailwindcss').Config} */ | /** @type {import('tailwindcss').Config} */ | ||||
| import type { Config } from 'tailwindcss' | import type { Config } from 'tailwindcss' | ||||
| import { DARK_COLOUR_SHADE, | |||||
| LIGHT_COLOUR_SHADE, | |||||
| TAG_COLOUR } from './src/consts' | |||||
| const colours = Object.values (TAG_COLOUR) | |||||
| export default { | export default { | ||||
| content: ['./index.html', | |||||
| './src/**/*.{js,ts,jsx,tsx}'], | |||||
| content: ['./src/**/*.{html,js,ts,jsx,tsx}'], | |||||
| safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), | |||||
| ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`), | |||||
| ...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`), | |||||
| ...colours.map (c => `dark:hover:text-${ c }-${ DARK_COLOUR_SHADE - 200 }`)], | |||||
| theme: { | theme: { | ||||
| extend: { | extend: { | ||||
| animation: { | |||||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite', | |||||
| }, | |||||
| colors: { | |||||
| red: { | |||||
| 925: '#5f1414', | |||||
| 975: '#230505', | |||||
| } | |||||
| }, | |||||
| keyframes: { | keyframes: { | ||||
| 'rainbow-scroll': { | 'rainbow-scroll': { | ||||
| '0%': { backgroundPosition: '0% 50%' }, | '0%': { backgroundPosition: '0% 50%' }, | ||||
| '100%': { backgroundPosition: '200% 50%' }, | '100%': { backgroundPosition: '200% 50%' }, | ||||
| }, | }, | ||||
| }, | }, | ||||
| animation: { | |||||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite', | |||||
| }, | |||||
| } | } | ||||
| }, | }, | ||||
| plugins: [], | plugins: [], | ||||