Browse Source

Merge branch 'main' into '#106'

'#106'
みてるぞ 20 hours ago
parent
commit
250ee3f011
16 changed files with 198 additions and 102 deletions
  1. +3
    -5
      backend/app/controllers/nico_tags_controller.rb
  2. +6
    -14
      backend/app/controllers/posts_controller.rb
  3. +5
    -6
      backend/app/controllers/tags_controller.rb
  4. +4
    -7
      backend/app/controllers/wiki_pages_controller.rb
  5. +16
    -0
      backend/app/representations/WikiPageRepr.rb
  6. +16
    -0
      backend/app/representations/post_repr.rb
  7. +16
    -0
      backend/app/representations/tag_repr.rb
  8. +23
    -5
      frontend/src/components/PostEmbed.tsx
  9. +28
    -11
      frontend/src/components/PostFormTagsArea.tsx
  10. +47
    -28
      frontend/src/components/PostOriginalCreatedTimeField.tsx
  11. +2
    -4
      frontend/src/components/common/DateTimeField.tsx
  12. +5
    -4
      frontend/src/lib/utils.ts
  13. +9
    -5
      frontend/src/pages/posts/PostHistoryPage.tsx
  14. +5
    -5
      frontend/src/pages/tags/NicoTagListPage.tsx
  15. +7
    -3
      frontend/src/pages/wiki/WikiHistoryPage.tsx
  16. +6
    -5
      frontend/src/pages/wiki/WikiSearchPage.tsx

+ 3
- 5
backend/app/controllers/nico_tags_controller.rb View File

@@ -1,6 +1,4 @@
class NicoTagsController < ApplicationController
TAG_JSON = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze

def index
limit = (params[:limit] || 20).to_i
cursor = params[:cursor].presence
@@ -19,8 +17,8 @@ class NicoTagsController < ApplicationController
end

render json: { tags: tags.map { |tag|
tag.as_json(TAG_JSON).merge(linked_tags: tag.linked_tags.map { |lt|
lt.as_json(TAG_JSON)
TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt|
TagRepr.base(lt)
})
}, next_cursor: }
end
@@ -41,6 +39,6 @@ class NicoTagsController < ApplicationController
tag.linked_tags = linked_tags
tag.save!

render json: tag.linked_tags.map { |t| t.as_json(TAG_JSON) }, status: :ok
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
end
end

+ 6
- 14
backend/app/controllers/posts_controller.rb View File

@@ -36,8 +36,7 @@ class PostsController < ApplicationController
end

render json: { posts: posts.map { |post|
post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }).tap do |json|
PostRepr.base(post).tap do |json|
json['thumbnail'] =
if post.thumbnail.attached?
rails_storage_proxy_url(post.thumbnail, only_path: false)
@@ -60,10 +59,7 @@ class PostsController < ApplicationController

viewed = current_user&.viewed?(post) || false

render json: (post
.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } })
.merge(viewed:))
render json: PostRepr.base(post).merge(viewed:)
end

def show
@@ -102,9 +98,7 @@ class PostsController < ApplicationController
sync_post_tags!(post, tags)

post.reload
render json: post.as_json(include: { tags: { only: [:id, :category, :post_count],
methods: [:name, :has_wiki] } }),
status: :created
render json: PostRepr.base(post), status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
@@ -170,7 +164,7 @@ class PostsController < ApplicationController

events = []
pts.each do |pt|
tag = pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki])
tag = TagRepr.base(pt.tag)
post = pt.post

events << Event.new(
@@ -269,8 +263,7 @@ class PostsController < ApplicationController
return nil unless tag

if path.include?(tag_id)
return tag.as_json(only: [:id, :category, :post_count],
methods: [:name, :has_wiki]).merge(children: [])
return TagRepr.base(tag).merge(children: [])
end

if memo.key?(tag_id)
@@ -282,8 +275,7 @@ class PostsController < ApplicationController

children = child_ids.filter_map { |cid| build_node.(cid, new_path) }

memo[tag_id] = tag.as_json(only: [:id, :category, :post_count],
methods: [:name, :has_wiki]).merge(children:)
memo[tag_id] = TagRepr.base(tag).merge(children:)
end

root_ids.filter_map { |id| build_node.call(id, []) }


+ 5
- 6
backend/app/controllers/tags_controller.rb View File

@@ -13,7 +13,7 @@ class TagsController < ApplicationController
tags = tags.where(posts: { id: post_id })
end

render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
render json: TagRepr.base(tags)
end

def autocomplete
@@ -57,8 +57,7 @@ class TagsController < ApplicationController
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a

render json: tags.map { |tag|
tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
.merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
end

@@ -67,7 +66,7 @@ class TagsController < ApplicationController
.includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id])
if tag
render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
render json: TagRepr.base(tag)
else
head :not_found
end
@@ -81,7 +80,7 @@ class TagsController < ApplicationController
.includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: })
if tag
render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki])
render json: TagRepr.base(tag)
else
head :not_found
end
@@ -104,6 +103,6 @@ class TagsController < ApplicationController
tag.update!(category:)
end

render json: tag.as_json(methods: [:name])
render json: TagRepr.base(tag)
end
end

+ 4
- 7
backend/app/controllers/wiki_pages_controller.rb View File

@@ -4,14 +4,12 @@ class WikiPagesController < ApplicationController
def index
title = params[:title].to_s.strip
if title.blank?
return render json: WikiPage.joins(:tag_name)
.includes(:tag_name)
.as_json(methods: [:title])
return render json: WikiPageRepr.base(WikiPage.joins(:tag_name).includes(:tag_name))
end

q = WikiPage.joins(:tag_name).includes(:tag_name)
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%")
render json: q.limit(20).as_json(methods: [:title])
render json: WikiPageRepr.base(q.limit(20))
end

def show
@@ -98,7 +96,7 @@ class WikiPagesController < ApplicationController
message = params[:message].presence
Wiki::Commit.content!(page:, body:, created_user: current_user, message:)

render json: page.as_json(methods: [:title]), status: :created
render json: WikiPageRepr.base(page), status: :created
else
render json: { errors: page.errors.full_messages },
status: :unprocessable_entity
@@ -174,8 +172,7 @@ class WikiPagesController < ApplicationController
succ = page.succ_revision_id(revision_id)
updated_at = rev.created_at

render json: page.as_json(methods: [:title])
.merge(body:, revision_id:, pred:, succ:, updated_at:)
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end

def find_revision page


+ 16
- 0
backend/app/representations/WikiPageRepr.rb View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true


module WikiPageRepr
BASE = { methods: [:title] }.freeze

module_function

def base wiki_page
wiki_page.as_json(BASE)
end

def many wiki_pages
wiki_pages.map { |p| base(p) }
end
end

+ 16
- 0
backend/app/representations/post_repr.rb View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true


module PostRepr
BASE = { include: { tags: TagRepr::BASE } }.freeze

module_function

def base post
post.as_json(BASE)
end

def many posts
posts.map { |p| base(p) }
end
end

+ 16
- 0
backend/app/representations/tag_repr.rb View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true


module TagRepr
BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze

module_function

def base tag
tag.as_json(BASE)
end

def many tags
tags.map { |t| base(t) }
end
end

+ 23
- 5
frontend/src/components/PostEmbed.tsx View File

@@ -18,17 +18,35 @@ export default (({ post }: Props) => {
{
case 'nicovideo.jp':
{
const [videoId] = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)!
const mVideoId = url.pathname.match (/(?<=\/watch\/)[a-zA-Z0-9]+?(?=\/|$)/)
if (!(mVideoId))
break

const [videoId] = mVideoId

return <NicoViewer id={videoId} width={640} height={360}/>
}

case 'twitter.com':
case 'x.com':
const [userId] = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)!
const [statusId] = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)!
return <TwitterEmbed userId={userId} statusId={statusId}/>
{
const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)
const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)
if (!(mUserId) || !(mStatusId))
break

const [userId] = mUserId
const [statusId] = mStatusId

return <TwitterEmbed userId={userId} statusId={statusId}/>
}

case 'youtube.com':
{
const videoId = url.searchParams.get ('v')!
const videoId = url.searchParams.get ('v')
if (!(videoId))
break

return (
<YoutubeEmbed videoId={videoId} opts={{ playerVars: {
playsinline: 1,


+ 28
- 11
frontend/src/components/PostFormTagsArea.tsx View File

@@ -25,8 +25,8 @@ const getTokenAt = (value: string, pos: number) => {
}


const replaceToken = (value: string, start: number, end: number, text: string) => (
`${ value.slice (0, start) }${ text }${ value.slice (end) }`)
const replaceToken = (value: string, start: number, end: number, text: string) =>
`${ value.slice (0, start) }${ text }${ value.slice (end) }`


type Props = {
@@ -38,16 +38,17 @@ export default (({ tags, setTags }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null)

const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)

const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false)
const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name)
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue)
requestAnimationFrame (async () => {
const p = bounds.start + tag.name.length
const p = bounds.start + tag.name.length + 1
textarea.selectionStart = textarea.selectionEnd = p
textarea.focus ()
await recompute (p, newValue)
@@ -56,14 +57,21 @@ export default (({ tags, setTags }: Props) => {

const recompute = async (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos)
if (!(token.trim ()))
{
setSuggestionsVsbl (false)
return
}

setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token } })

const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token, nico: '0' } })
setSuggestions (data.filter (t => t.postCount > 0))
setSuggestionsVsbl (suggestions.length > 0)
}

return (
<div>
<div className="relative w-full">
<Label>タグ</Label>
<TextArea
ref={ref}
@@ -72,11 +80,20 @@ export default (({ tags, setTags }: Props) => {
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
}}
onFocus={() => {
setFocused (true)
}}
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0
? suggestions
: [] as Tag[]}
activeIndex={-1}
onSelect={handleTagSelect}/>)}
</div>)
}) satisfies FC<Props>

+ 47
- 28
frontend/src/components/PostOriginalCreatedTimeField.tsx View File

@@ -1,5 +1,6 @@
import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button'

import type { FC } from 'react'

@@ -16,34 +17,52 @@ export default (({ originalCreatedFrom,
setOriginalCreatedBefore }: Props) => (
<div>
<Label>オリジナルの作成日時</Label>
<div className="my-1">
<DateTimeField
className="mr-2"
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getSeconds () === 0)
{
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
}
else
d.setSeconds (d.getSeconds () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
以降
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return

const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ())
}}/>
以降
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
onClick={() => {
setOriginalCreatedFrom (null)
}}>
リセット
</Button>
</div>
</div>
<div className="my-1">
<DateTimeField
className="mr-2"
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
</div>
<div>
<Button
className="bg-gray-600 text-white rounded"
onClick={() => {
setOriginalCreatedBefore (null)
}}>
リセット
</Button>
</div>
</div>
</div>)) satisfies FC<Props>

+ 2
- 4
frontend/src/components/common/DateTimeField.tsx View File

@@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'
import type { FC, FocusEvent } from 'react'


const pad = (n: number) => n.toString ().padStart (2, '0')
const pad = (n: number): string => n.toString ().padStart (2, '0')


const toDateTimeLocalValue = (d: Date) => {
@@ -14,8 +14,7 @@ const toDateTimeLocalValue = (d: Date) => {
const day = pad (d.getDate ())
const h = pad (d.getHours ())
const min = pad (d.getMinutes ())
const s = pad (d.getSeconds ())
return `${ y }-${ m }-${ day }T${ h }:${ min }:${ s }`
return `${ y }-${ m }-${ day }T${ h }:${ min }:00`
}


@@ -37,7 +36,6 @@ export default (({ value, onChange, className, onBlur }: Props) => {
<input
className={cn ('border rounded p-2', className)}
type="datetime-local"
step={1}
value={local}
onChange={ev => {
const v = ev.target.value


+ 5
- 4
frontend/src/lib/utils.ts View File

@@ -1,6 +1,7 @@
import { clsx, type ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn (...inputs: ClassValue[]) {
return twMerge(clsx(...inputs))
}
import type { ClassValue } from 'clsx'


export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))

+ 9
- 5
frontend/src/pages/posts/PostHistoryPage.tsx View File

@@ -10,6 +10,7 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { fetchPostChanges } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'

import type { FC } from 'react'

@@ -44,7 +45,7 @@ export default (() => {
{loading ? 'Loading...' : (
<>
<table className="table-auto w-full border-collapse">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">投稿</th>
<th className="p-2 text-left">変更</th>
@@ -64,9 +65,12 @@ export default (() => {
++rowsCnt
}
return (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}
className={cn ('even:bg-gray-100 dark:even:bg-gray-700',
withPost && 'border-t')}>
{withPost && (
<td className="align-top" rowSpan={rowsCnt}>
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
rowSpan={rowsCnt}>
<PrefetchLink to={`/posts/${ change.post.id }`}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url}
@@ -74,11 +78,11 @@ export default (() => {
className="w-40"/>
</PrefetchLink>
</td>)}
<td>
<td className="p-2">
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
{`を${ change.changeType === 'add' ? '記載' : '消除' }`}
</td>
<td>
<td className="p-2">
{change.user ? (
<PrefetchLink to={`/users/${ change.user.id }`}>
{change.user.name}


+ 5
- 5
frontend/src/pages/tags/NicoTagListPage.tsx View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'

import TagLink from '@/components/TagLink'
import SectionTitle from '@/components/common/SectionTitle'
import PageTitle from '@/components/common/PageTitle'
import TextArea from '@/components/common/TextArea'
import MainArea from '@/components/layout/MainArea'
import { toast } from '@/components/ui/use-toast'
@@ -92,13 +92,13 @@ export default ({ user }: Props) => {
</Helmet>

<div className="max-w-xl">
<SectionTitle>ニコニコ連携</SectionTitle>
<PageTitle>ニコニコ連携</PageTitle>
</div>

<div className="mt-4">
{nicoTags.length > 0 && (
<table className="table-auto w-full border-collapse mb-4">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">ニコニコタグ</th>
<th className="p-2 text-left">連携タグ</th>
@@ -107,7 +107,7 @@ export default ({ user }: Props) => {
</thead>
<tbody>
{nicoTags.map ((tag, i) => (
<tr key={i}>
<tr key={i} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<TagLink tag={tag} withWiki={false} withCount={false}/>
</td>
@@ -125,7 +125,7 @@ export default ({ user }: Props) => {
</span>))}
</td>
{memberFlg && (
<td>
<td className="p-2">
<a href="#" onClick={ev => {
ev.preventDefault ()
handleEdit (tag.id)


+ 7
- 3
frontend/src/pages/wiki/WikiHistoryPage.tsx View File

@@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'

import PrefetchLink from '@/components/PrefetchLink'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
@@ -28,8 +29,11 @@ export default () => {
<Helmet>
<title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title>
</Helmet>

<PageTitle>Wiki 履歴</PageTitle>

<table className="table-auto w-full border-collapse">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th></th>
<th className="p-2 text-left">タイトル</th>
@@ -39,8 +43,8 @@ export default () => {
</thead>
<tbody>
{changes.map (change => (
<tr key={change.revisionId}>
<td>
<tr key={change.revisionId} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
{change.pred != null && (
<PrefetchLink
to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}>


+ 6
- 5
frontend/src/pages/wiki/WikiSearchPage.tsx View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'

import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
@@ -35,8 +35,9 @@ export default () => {
<Helmet>
<title>Wiki | {SITE_TITLE}</title>
</Helmet>

<div className="max-w-xl">
<SectionTitle>Wiki</SectionTitle>
<PageTitle>Wiki</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<div>
@@ -68,7 +69,7 @@ export default () => {

<div className="mt-4">
<table className="table-auto w-full border-collapse">
<thead>
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">最終更新</th>
@@ -76,13 +77,13 @@ export default () => {
</thead>
<tbody>
{results.map (page => (
<tr key={page.id}>
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
{page.title}
</PrefetchLink>
</td>
<td className="p-2 text-gray-100 text-sm">
<td className="p-2">
{page.updatedAt}
</td>
</tr>))}


Loading…
Cancel
Save