@@ -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 { Link } from 'react-router-dom' | |||
import TagLink from '@/components/TagLink' | |||
import TagSearch from '@/components/TagSearch' | |||
import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
import SidebarComponent from '@/components/layout/SidebarComponent' | |||
@@ -14,47 +14,48 @@ type Props = { post: Post | null } | |||
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: 'ニジラー', | |||
meme: '原作・ネタ元・ミーム等', | |||
character: 'キャラクター', | |||
general: '一般', | |||
material: '素材', | |||
meta: 'メタタグ', | |||
nico: 'ニコニコタグ' } | |||
useEffect (() => { | |||
if (!(post)) | |||
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]) | |||
return ( | |||
<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>) | |||
} |
@@ -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 { 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 SectionTitle from '@/components/common/SectionTitle' | |||
import SidebarComponent from '@/components/layout/SidebarComponent' | |||
@@ -28,6 +29,7 @@ export default ({ posts }: Props) => { | |||
useEffect (() => { | |||
const tagsTmp: TagByCategory = { } | |||
let cnt = 0 | |||
loop: | |||
for (const post of posts) | |||
{ | |||
@@ -35,6 +37,7 @@ export default ({ posts }: Props) => { | |||
{ | |||
if (!(tag.category in tagsTmp)) | |||
tagsTmp[tag.category] = [] | |||
if (!(tagsTmp[tag.category].map (t => t.id).includes (tag.id))) | |||
{ | |||
tagsTmp[tag.category].push (tag) | |||
@@ -44,8 +47,10 @@ export default ({ posts }: Props) => { | |||
} | |||
} | |||
} | |||
for (const cat of Object.keys (tagsTmp)) | |||
tagsTmp[cat].sort ((tagA, tagB) => tagA.name < tagB.name ? -1 : 1) | |||
setTags (tagsTmp) | |||
}, [posts]) | |||
@@ -57,10 +62,7 @@ export default ({ posts }: Props) => { | |||
{CATEGORIES.flatMap (cat => cat in tags ? ( | |||
tags[cat].map (tag => ( | |||
<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>))) : [])} | |||
</ul> | |||
<SectionTitle>関聯</SectionTitle> | |||
@@ -17,7 +17,6 @@ const Menu = { None: 'None', | |||
User: 'User', | |||
Tag: 'Tag', | |||
Wiki: 'Wiki' } as const | |||
type Menu = typeof Menu[keyof typeof Menu] | |||
@@ -38,9 +37,9 @@ export default ({ user }: Props) => { | |||
const MyLink = ({ to, title, base }: { to: string | |||
title: 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) | |||
? 'bg-gray-700 px-4 font-bold' | |||
? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold' | |||
: 'px-2'))}> | |||
{title} | |||
</Link>) | |||
@@ -121,11 +120,9 @@ export default ({ user }: Props) => { | |||
try | |||
{ | |||
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 tagData: any = tagRes.data | |||
const tag = toCamel (tagData, { deep: true }) as Tag | |||
setPostCount (tag.postCount) | |||
@@ -139,25 +136,32 @@ export default ({ user }: Props) => { | |||
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"> | |||
<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="/tags" title="タグ" /> | |||
<MyLink to="/wiki/ヘルプ:ホーム" base="/wiki" title="Wiki" /> | |||
<MyLink to="/users/settings" base="/users" title="ニジラー" /> | |||
</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 || '名もなきニジラー'} | |||
</Button>)} | |||
</div> | |||
</Link> | |||
</div>} | |||
</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 Separator = () => <span className="flex items-center px-2">|</span> | |||
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', | |||
'character', | |||
'general', | |||
'material', | |||
'meta', | |||
'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 ViewFlagBehavior = { OnShowedDetail: 1, | |||
@@ -2,13 +2,25 @@ | |||
@tailwind components; | |||
@tailwind utilities; | |||
@layer base { | |||
body { | |||
@layer base | |||
{ | |||
body | |||
{ | |||
@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; | |||
line-height: 1.5; | |||
font-weight: 400; | |||
@@ -23,16 +35,14 @@ | |||
-moz-osx-font-smoothing: grayscale; | |||
} | |||
a { | |||
a | |||
{ | |||
font-weight: 500; | |||
color: #646cff; | |||
text-decoration: inherit; | |||
} | |||
a:hover { | |||
color: #535bf2; | |||
} | |||
body { | |||
body | |||
{ | |||
margin: 0; | |||
display: flex; | |||
place-items: center; | |||
@@ -40,12 +50,14 @@ body { | |||
min-height: 100vh; | |||
} | |||
h1 { | |||
h1 | |||
{ | |||
font-size: 3.2em; | |||
line-height: 1.1; | |||
} | |||
button { | |||
button | |||
{ | |||
border-radius: 8px; | |||
border: 1px solid transparent; | |||
padding: 0.6em 1.2em; | |||
@@ -56,23 +68,29 @@ button { | |||
cursor: pointer; | |||
transition: border-color 0.25s; | |||
} | |||
button:hover { | |||
button:hover | |||
{ | |||
border-color: #646cff; | |||
} | |||
button:focus, | |||
button:focus-visible { | |||
button:focus-visible | |||
{ | |||
outline: 4px auto -webkit-focus-ring-color; | |||
} | |||
@media (prefers-color-scheme: light) { | |||
:root { | |||
@media (prefers-color-scheme: light) | |||
{ | |||
:root | |||
{ | |||
color: #213547; | |||
background-color: #ffffff; | |||
} | |||
a:hover { | |||
a:hover | |||
{ | |||
color: #747bff; | |||
} | |||
button { | |||
button | |||
{ | |||
background-color: #f9f9f9; | |||
} | |||
} |
@@ -51,7 +51,7 @@ export default ({ user }: Props) => { | |||
if (!(id)) | |||
return | |||
void (async () => { | |||
const fetchPost = async () => { | |||
try | |||
{ | |||
const res = await axios.get (`${ API_BASE_URL }/posts/${ id }`, { headers: { | |||
@@ -63,7 +63,8 @@ export default ({ user }: Props) => { | |||
if (axios.isAxiosError (err)) | |||
setStatus (err.status ?? 200) | |||
} | |||
}) () | |||
} | |||
fetchPost () | |||
}, [id]) | |||
useEffect (() => { | |||
@@ -93,16 +93,16 @@ export default () => { | |||
<Tab name="広場"> | |||
{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>) | |||
: !(loading) && '広場には何もありませんよ.'} | |||
{loading && 'Loading...'} | |||
@@ -112,7 +112,7 @@ export default () => { | |||
<Tab name="Wiki" init={!(posts.length)}> | |||
<WikiBody body={wikiPage.body} /> | |||
<div className="my-2"> | |||
<Link to={`/wiki/${ wikiPage.title }`}>Wiki を見る</Link> | |||
<Link to={`/wiki/${ encodeURIComponent (wikiPage.title) }`}>Wiki を見る</Link> | |||
</div> | |||
</Tab>)} | |||
</TabGroup> | |||
@@ -3,6 +3,7 @@ import toCamel from 'camelcase-keys' | |||
import { useEffect, useRef, useState } from 'react' | |||
import { Helmet } from 'react-helmet-async' | |||
import TagLink from '@/components/TagLink' | |||
import SectionTitle from '@/components/common/SectionTitle' | |||
import TextArea from '@/components/common/TextArea' | |||
import MainArea from '@/components/layout/MainArea' | |||
@@ -15,10 +16,10 @@ type Props = { user: User | null } | |||
export default ({ user }: Props) => { | |||
const [nicoTags, setNicoTags] = useState<NicoTag[]> ([]) | |||
const [cursor, setCursor] = useState ('') | |||
const [loading, setLoading] = useState (false) | |||
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 loaderRef = useRef<HTMLDivElement | null> (null) | |||
@@ -56,6 +57,10 @@ export default ({ user }: Props) => { | |||
'Content-Type': 'multipart/form-data', | |||
'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||
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 (' ') })) | |||
toast ({ title: '更新しました.' }) | |||
@@ -106,13 +111,10 @@ export default ({ user }: Props) => { | |||
</tr> | |||
</thead> | |||
<tbody> | |||
{nicoTags.map (tag => ( | |||
<tr key={tag.id}> | |||
{nicoTags.map ((tag, i) => ( | |||
<tr key={i}> | |||
<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 className="p-2"> | |||
{editing[tag.id] | |||
@@ -120,7 +122,12 @@ export default ({ user }: Props) => { | |||
<TextArea value={rawTags[tag.id]} onChange={ev => { | |||
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> | |||
{memberFlg && ( | |||
<td> | |||
@@ -2,9 +2,8 @@ import { CATEGORIES, USER_ROLES, ViewFlagBehavior } from '@/consts' | |||
export type Category = typeof CATEGORIES[number] | |||
export type NicoTag = { | |||
id: number | |||
name: string | |||
export type NicoTag = Tag & { | |||
category: 'nico' | |||
linkedTags: Tag[] } | |||
export type Post = { | |||
@@ -1,20 +1,35 @@ | |||
/** @type {import('tailwindcss').Config} */ | |||
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 { | |||
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: { | |||
extend: { | |||
animation: { | |||
'rainbow-scroll': 'rainbow-scroll .25s linear infinite', | |||
}, | |||
colors: { | |||
red: { | |||
925: '#5f1414', | |||
975: '#230505', | |||
} | |||
}, | |||
keyframes: { | |||
'rainbow-scroll': { | |||
'0%': { backgroundPosition: '0% 50%' }, | |||
'100%': { backgroundPosition: '200% 50%' }, | |||
}, | |||
}, | |||
animation: { | |||
'rainbow-scroll': 'rainbow-scroll .25s linear infinite', | |||
}, | |||
} | |||
}, | |||
plugins: [], | |||