| @@ -1,6 +1,4 @@ | |||||
| class NicoTagsController < ApplicationController | class NicoTagsController < ApplicationController | ||||
| TAG_JSON = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze | |||||
| def index | def index | ||||
| limit = (params[:limit] || 20).to_i | limit = (params[:limit] || 20).to_i | ||||
| cursor = params[:cursor].presence | cursor = params[:cursor].presence | ||||
| @@ -19,8 +17,8 @@ class NicoTagsController < ApplicationController | |||||
| end | end | ||||
| render json: { tags: tags.map { |tag| | 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: } | }, next_cursor: } | ||||
| end | end | ||||
| @@ -41,6 +39,6 @@ class NicoTagsController < ApplicationController | |||||
| tag.linked_tags = linked_tags | tag.linked_tags = linked_tags | ||||
| tag.save! | 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 | ||||
| end | end | ||||
| @@ -36,8 +36,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| render json: { posts: posts.map { |post| | 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'] = | json['thumbnail'] = | ||||
| if post.thumbnail.attached? | if post.thumbnail.attached? | ||||
| rails_storage_proxy_url(post.thumbnail, only_path: false) | rails_storage_proxy_url(post.thumbnail, only_path: false) | ||||
| @@ -60,10 +59,7 @@ class PostsController < ApplicationController | |||||
| viewed = current_user&.viewed?(post) || false | 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 | end | ||||
| def show | def show | ||||
| @@ -102,9 +98,7 @@ class PostsController < ApplicationController | |||||
| sync_post_tags!(post, tags) | sync_post_tags!(post, tags) | ||||
| post.reload | 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 | else | ||||
| render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | ||||
| end | end | ||||
| @@ -170,7 +164,7 @@ class PostsController < ApplicationController | |||||
| events = [] | events = [] | ||||
| pts.each do |pt| | pts.each do |pt| | ||||
| tag = pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki]) | |||||
| tag = TagRepr.base(pt.tag) | |||||
| post = pt.post | post = pt.post | ||||
| events << Event.new( | events << Event.new( | ||||
| @@ -269,8 +263,7 @@ class PostsController < ApplicationController | |||||
| return nil unless tag | return nil unless tag | ||||
| if path.include?(tag_id) | 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 | end | ||||
| if memo.key?(tag_id) | if memo.key?(tag_id) | ||||
| @@ -282,8 +275,7 @@ class PostsController < ApplicationController | |||||
| children = child_ids.filter_map { |cid| build_node.(cid, new_path) } | 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 | end | ||||
| root_ids.filter_map { |id| build_node.call(id, []) } | root_ids.filter_map { |id| build_node.call(id, []) } | ||||
| @@ -13,7 +13,7 @@ class TagsController < ApplicationController | |||||
| tags = tags.where(posts: { id: post_id }) | tags = tags.where(posts: { id: post_id }) | ||||
| end | end | ||||
| render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | |||||
| render json: TagRepr.base(tags) | |||||
| end | end | ||||
| def autocomplete | def autocomplete | ||||
| @@ -57,8 +57,7 @@ class TagsController < ApplicationController | |||||
| tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a | tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a | ||||
| render json: tags.map { |tag| | 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 | end | ||||
| @@ -67,7 +66,7 @@ class TagsController < ApplicationController | |||||
| .includes(:tag_name, tag_name: :wiki_page) | .includes(:tag_name, tag_name: :wiki_page) | ||||
| .find_by(id: params[:id]) | .find_by(id: params[:id]) | ||||
| if tag | if tag | ||||
| render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | |||||
| render json: TagRepr.base(tag) | |||||
| else | else | ||||
| head :not_found | head :not_found | ||||
| end | end | ||||
| @@ -81,7 +80,7 @@ class TagsController < ApplicationController | |||||
| .includes(:tag_name, tag_name: :wiki_page) | .includes(:tag_name, tag_name: :wiki_page) | ||||
| .find_by(tag_names: { name: }) | .find_by(tag_names: { name: }) | ||||
| if tag | if tag | ||||
| render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | |||||
| render json: TagRepr.base(tag) | |||||
| else | else | ||||
| head :not_found | head :not_found | ||||
| end | end | ||||
| @@ -104,6 +103,6 @@ class TagsController < ApplicationController | |||||
| tag.update!(category:) | tag.update!(category:) | ||||
| end | end | ||||
| render json: tag.as_json(methods: [:name]) | |||||
| render json: TagRepr.base(tag) | |||||
| end | end | ||||
| end | end | ||||
| @@ -4,14 +4,12 @@ class WikiPagesController < ApplicationController | |||||
| def index | def index | ||||
| title = params[:title].to_s.strip | title = params[:title].to_s.strip | ||||
| if title.blank? | 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 | end | ||||
| q = WikiPage.joins(:tag_name).includes(:tag_name) | q = WikiPage.joins(:tag_name).includes(:tag_name) | ||||
| .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | .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 | end | ||||
| def show | def show | ||||
| @@ -98,7 +96,7 @@ class WikiPagesController < ApplicationController | |||||
| message = params[:message].presence | message = params[:message].presence | ||||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | 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 | else | ||||
| render json: { errors: page.errors.full_messages }, | render json: { errors: page.errors.full_messages }, | ||||
| status: :unprocessable_entity | status: :unprocessable_entity | ||||
| @@ -174,8 +172,7 @@ class WikiPagesController < ApplicationController | |||||
| succ = page.succ_revision_id(revision_id) | succ = page.succ_revision_id(revision_id) | ||||
| updated_at = rev.created_at | 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 | end | ||||
| def find_revision page | def find_revision page | ||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -18,17 +18,35 @@ export default (({ post }: Props) => { | |||||
| { | { | ||||
| case 'nicovideo.jp': | 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}/> | return <NicoViewer id={videoId} width={640} height={360}/> | ||||
| } | } | ||||
| case 'twitter.com': | case 'twitter.com': | ||||
| case 'x.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': | case 'youtube.com': | ||||
| { | { | ||||
| const videoId = url.searchParams.get ('v')! | |||||
| const videoId = url.searchParams.get ('v') | |||||
| if (!(videoId)) | |||||
| break | |||||
| return ( | return ( | ||||
| <YoutubeEmbed videoId={videoId} opts={{ playerVars: { | <YoutubeEmbed videoId={videoId} opts={{ playerVars: { | ||||
| playsinline: 1, | playsinline: 1, | ||||
| @@ -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 = { | type Props = { | ||||
| @@ -38,16 +38,17 @@ export default (({ tags, setTags }: Props) => { | |||||
| const ref = useRef<HTMLTextAreaElement> (null) | const ref = useRef<HTMLTextAreaElement> (null) | ||||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | ||||
| const [focused, setFocused] = useState (false) | |||||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | const [suggestions, setSuggestions] = useState<Tag[]> ([]) | ||||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | ||||
| const handleTagSelect = (tag: Tag) => { | const handleTagSelect = (tag: Tag) => { | ||||
| setSuggestionsVsbl (false) | setSuggestionsVsbl (false) | ||||
| const textarea = ref.current! | 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) | setTags (newValue) | ||||
| requestAnimationFrame (async () => { | requestAnimationFrame (async () => { | ||||
| const p = bounds.start + tag.name.length | |||||
| const p = bounds.start + tag.name.length + 1 | |||||
| textarea.selectionStart = textarea.selectionEnd = p | textarea.selectionStart = textarea.selectionEnd = p | ||||
| textarea.focus () | textarea.focus () | ||||
| await recompute (p, newValue) | await recompute (p, newValue) | ||||
| @@ -56,14 +57,21 @@ export default (({ tags, setTags }: Props) => { | |||||
| const recompute = async (pos: number, v: string = tags) => { | const recompute = async (pos: number, v: string = tags) => { | ||||
| const { start, end, token } = getTokenAt (v, pos) | const { start, end, token } = getTokenAt (v, pos) | ||||
| if (!(token.trim ())) | |||||
| { | |||||
| setSuggestionsVsbl (false) | |||||
| return | |||||
| } | |||||
| setBounds ({ start, end }) | 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)) | setSuggestions (data.filter (t => t.postCount > 0)) | ||||
| setSuggestionsVsbl (suggestions.length > 0) | setSuggestionsVsbl (suggestions.length > 0) | ||||
| } | } | ||||
| return ( | return ( | ||||
| <div> | |||||
| <div className="relative w-full"> | |||||
| <Label>タグ</Label> | <Label>タグ</Label> | ||||
| <TextArea | <TextArea | ||||
| ref={ref} | ref={ref} | ||||
| @@ -72,11 +80,20 @@ export default (({ tags, setTags }: Props) => { | |||||
| onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { | onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { | ||||
| const pos = (ev.target as HTMLTextAreaElement).selectionStart | const pos = (ev.target as HTMLTextAreaElement).selectionStart | ||||
| await recompute (pos) | 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>) | </div>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -1,5 +1,6 @@ | |||||
| import DateTimeField from '@/components/common/DateTimeField' | import DateTimeField from '@/components/common/DateTimeField' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import { Button } from '@/components/ui/button' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -16,34 +17,52 @@ export default (({ originalCreatedFrom, | |||||
| setOriginalCreatedBefore }: Props) => ( | setOriginalCreatedBefore }: Props) => ( | ||||
| <div> | <div> | ||||
| <Label>オリジナルの作成日時</Label> | <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> | ||||
| <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> | ||||
| </div>)) satisfies FC<Props> | </div>)) satisfies FC<Props> | ||||
| @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils' | |||||
| import type { FC, FocusEvent } from 'react' | 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) => { | const toDateTimeLocalValue = (d: Date) => { | ||||
| @@ -14,8 +14,7 @@ const toDateTimeLocalValue = (d: Date) => { | |||||
| const day = pad (d.getDate ()) | const day = pad (d.getDate ()) | ||||
| const h = pad (d.getHours ()) | const h = pad (d.getHours ()) | ||||
| const min = pad (d.getMinutes ()) | 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 | <input | ||||
| className={cn ('border rounded p-2', className)} | className={cn ('border rounded p-2', className)} | ||||
| type="datetime-local" | type="datetime-local" | ||||
| step={1} | |||||
| value={local} | value={local} | ||||
| onChange={ev => { | onChange={ev => { | ||||
| const v = ev.target.value | const v = ev.target.value | ||||
| @@ -1,6 +1,7 @@ | |||||
| import { clsx, type ClassValue } from 'clsx' | |||||
| import { clsx } from 'clsx' | |||||
| import { twMerge } from 'tailwind-merge' | 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)) | |||||
| @@ -10,6 +10,7 @@ import MainArea from '@/components/layout/MainArea' | |||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { fetchPostChanges } from '@/lib/posts' | import { fetchPostChanges } from '@/lib/posts' | ||||
| import { postsKeys } from '@/lib/queryKeys' | import { postsKeys } from '@/lib/queryKeys' | ||||
| import { cn } from '@/lib/utils' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -44,7 +45,7 @@ export default (() => { | |||||
| {loading ? 'Loading...' : ( | {loading ? 'Loading...' : ( | ||||
| <> | <> | ||||
| <table className="table-auto w-full border-collapse"> | <table className="table-auto w-full border-collapse"> | ||||
| <thead> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <tr> | <tr> | ||||
| <th className="p-2 text-left">投稿</th> | <th className="p-2 text-left">投稿</th> | ||||
| <th className="p-2 text-left">変更</th> | <th className="p-2 text-left">変更</th> | ||||
| @@ -64,9 +65,12 @@ export default (() => { | |||||
| ++rowsCnt | ++rowsCnt | ||||
| } | } | ||||
| return ( | 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 && ( | {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 }`}> | <PrefetchLink to={`/posts/${ change.post.id }`}> | ||||
| <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | <img src={change.post.thumbnail || change.post.thumbnailBase || undefined} | ||||
| alt={change.post.title || change.post.url} | alt={change.post.title || change.post.url} | ||||
| @@ -74,11 +78,11 @@ export default (() => { | |||||
| className="w-40"/> | className="w-40"/> | ||||
| </PrefetchLink> | </PrefetchLink> | ||||
| </td>)} | </td>)} | ||||
| <td> | |||||
| <td className="p-2"> | |||||
| <TagLink tag={change.tag} withWiki={false} withCount={false}/> | <TagLink tag={change.tag} withWiki={false} withCount={false}/> | ||||
| {`を${ change.changeType === 'add' ? '記載' : '消除' }`} | {`を${ change.changeType === 'add' ? '記載' : '消除' }`} | ||||
| </td> | </td> | ||||
| <td> | |||||
| <td className="p-2"> | |||||
| {change.user ? ( | {change.user ? ( | ||||
| <PrefetchLink to={`/users/${ change.user.id }`}> | <PrefetchLink to={`/users/${ change.user.id }`}> | ||||
| {change.user.name} | {change.user.name} | ||||
| @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | |||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| @@ -92,13 +92,13 @@ export default ({ user }: Props) => { | |||||
| </Helmet> | </Helmet> | ||||
| <div className="max-w-xl"> | <div className="max-w-xl"> | ||||
| <SectionTitle>ニコニコ連携</SectionTitle> | |||||
| <PageTitle>ニコニコ連携</PageTitle> | |||||
| </div> | </div> | ||||
| <div className="mt-4"> | <div className="mt-4"> | ||||
| {nicoTags.length > 0 && ( | {nicoTags.length > 0 && ( | ||||
| <table className="table-auto w-full border-collapse mb-4"> | <table className="table-auto w-full border-collapse mb-4"> | ||||
| <thead> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <tr> | <tr> | ||||
| <th className="p-2 text-left">ニコニコタグ</th> | <th className="p-2 text-left">ニコニコタグ</th> | ||||
| <th className="p-2 text-left">連携タグ</th> | <th className="p-2 text-left">連携タグ</th> | ||||
| @@ -107,7 +107,7 @@ export default ({ user }: Props) => { | |||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {nicoTags.map ((tag, i) => ( | {nicoTags.map ((tag, i) => ( | ||||
| <tr key={i}> | |||||
| <tr key={i} className="even:bg-gray-100 dark:even:bg-gray-700"> | |||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <TagLink tag={tag} withWiki={false} withCount={false}/> | <TagLink tag={tag} withWiki={false} withCount={false}/> | ||||
| </td> | </td> | ||||
| @@ -125,7 +125,7 @@ export default ({ user }: Props) => { | |||||
| </span>))} | </span>))} | ||||
| </td> | </td> | ||||
| {memberFlg && ( | {memberFlg && ( | ||||
| <td> | |||||
| <td className="p-2"> | |||||
| <a href="#" onClick={ev => { | <a href="#" onClick={ev => { | ||||
| ev.preventDefault () | ev.preventDefault () | ||||
| handleEdit (tag.id) | handleEdit (tag.id) | ||||
| @@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet-async' | |||||
| import { useLocation } from 'react-router-dom' | import { useLocation } from 'react-router-dom' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| @@ -28,8 +29,11 @@ export default () => { | |||||
| <Helmet> | <Helmet> | ||||
| <title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title> | <title>{`Wiki 変更履歴 | ${ SITE_TITLE }`}</title> | ||||
| </Helmet> | </Helmet> | ||||
| <PageTitle>Wiki 履歴</PageTitle> | |||||
| <table className="table-auto w-full border-collapse"> | <table className="table-auto w-full border-collapse"> | ||||
| <thead> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <tr> | <tr> | ||||
| <th></th> | <th></th> | ||||
| <th className="p-2 text-left">タイトル</th> | <th className="p-2 text-left">タイトル</th> | ||||
| @@ -39,8 +43,8 @@ export default () => { | |||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {changes.map (change => ( | {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 && ( | {change.pred != null && ( | ||||
| <PrefetchLink | <PrefetchLink | ||||
| to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}> | to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}> | ||||
| @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | |||||
| import PageTitle from '@/components/common/PageTitle' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| @@ -35,8 +35,9 @@ export default () => { | |||||
| <Helmet> | <Helmet> | ||||
| <title>Wiki | {SITE_TITLE}</title> | <title>Wiki | {SITE_TITLE}</title> | ||||
| </Helmet> | </Helmet> | ||||
| <div className="max-w-xl"> | <div className="max-w-xl"> | ||||
| <SectionTitle>Wiki</SectionTitle> | |||||
| <PageTitle>Wiki</PageTitle> | |||||
| <form onSubmit={handleSearch} className="space-y-2"> | <form onSubmit={handleSearch} className="space-y-2"> | ||||
| {/* タイトル */} | {/* タイトル */} | ||||
| <div> | <div> | ||||
| @@ -68,7 +69,7 @@ export default () => { | |||||
| <div className="mt-4"> | <div className="mt-4"> | ||||
| <table className="table-auto w-full border-collapse"> | <table className="table-auto w-full border-collapse"> | ||||
| <thead> | |||||
| <thead className="border-b-2 border-black dark:border-white"> | |||||
| <tr> | <tr> | ||||
| <th className="p-2 text-left">タイトル</th> | <th className="p-2 text-left">タイトル</th> | ||||
| <th className="p-2 text-left">最終更新</th> | <th className="p-2 text-left">最終更新</th> | ||||
| @@ -76,13 +77,13 @@ export default () => { | |||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {results.map (page => ( | {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"> | <td className="p-2"> | ||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}> | <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}> | ||||
| {page.title} | {page.title} | ||||
| </PrefetchLink> | </PrefetchLink> | ||||
| </td> | </td> | ||||
| <td className="p-2 text-gray-100 text-sm"> | |||||
| <td className="p-2"> | |||||
| {page.updatedAt} | {page.updatedAt} | ||||
| </td> | </td> | ||||
| </tr>))} | </tr>))} | ||||