From 790f39e95bcc25cc433ba278a6f7ff37d1b5930b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 14 Mar 2026 18:32:19 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=E6=97=A5=E3=81=A5=E3=81=91=E4=B8=8D?= =?UTF-8?q?=E8=A9=B3=E3=81=AE=E8=A1=A8=E7=A4=BA=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/utils.ts | 65 +++++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 95caded..0207b5f 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -10,17 +10,64 @@ export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Da export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs)) -export const dateString = (d: string | Date): string => - toDate (d).toLocaleString ('ja-JP-u-ca-japanese') +export const dateString = ( + d: string | Date, + unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null, +): string => + toDate (d).toLocaleString ( + 'ja-JP-u-ca-japanese', + { era: 'long', + year: 'numeric', + month: (unknown === 'month' ? undefined : 'long'), + day: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'numeric', + weekday: unknown != null && ['month', 'day'].includes (unknown) ? undefined : 'short', + hour: unknown == null || ['second', 'minute'].includes (unknown) ? 'numeric' : undefined, + minute: unknown == null || unknown === 'second' ? 'numeric' : undefined, + second: unknown == null ? 'numeric' : undefined }) -// TODO: 表示形式きしょすぎるので何とかする export const originalCreatedAtString = ( f: string | Date | null, b: string | Date | null, -): string => - ([f ? `${ dateString (f) } 以降` : '', - b ? `${ dateString (b) } より前` : ''] - .filter (Boolean) - .join (' ')) - || '不明' +): string => { + const from = f ? toDate (f) : null + const before = b ? toDate (b) : null + + if (from && before) + { + const diff = before.getTime () - from.getTime () + + if (diff <= 60_000 /* 1 分 */) + return dateString (from, 'second') + + if (from.getMinutes () === 0 && before.getMinutes () === 0) + { + if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000) + return dateString (from, 'minute') + ' (分不詳)' + + if (from.getHours () === 0 && before.getHours () === 0) + { + if (Math.abs (diff - 86_400_000) < 60_000) + return dateString (from, 'hour') + ' (時刻不詳)' + + if (from.getDate () === 1 && before.getDate () === 1) + { + if (2_419_200_000 /* 28 日 */ <= diff && diff < 2_764_800_000 /* 32 日 */) + return dateString (from, 'day') + ' (日不詳)' + + if (from.getMonth () === 0 && before.getMonth () === 0 + && (31_536_000_000 /* 365 日 */ <= diff + && diff < 31_708_800_000 /* 367 日 */)) + return dateString (from, 'month') + ' (月日不詳)' + } + } + } + } + + const rtn = ([from ? `${ dateString (from, 'second') }` : '', + '~', + before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : ''] + .filter (Boolean) + .join (' ')) + return rtn === '~' ? '年月日不詳' : rtn +} -- 2.34.1 From f8e4da6fcb54a0c9c90e9b5957abb2deea31c86c Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 14 Mar 2026 20:59:24 +0900 Subject: [PATCH 02/13] #61 --- frontend/src/App.tsx | 2 + frontend/src/components/TagDetailSidebar.tsx | 13 +- frontend/src/components/TopNav.tsx | 2 +- frontend/src/consts.ts | 10 + frontend/src/lib/posts.ts | 2 +- frontend/src/lib/queryKeys.ts | 1 + frontend/src/lib/tags.ts | 16 ++ frontend/src/lib/utils.ts | 10 +- frontend/src/pages/posts/PostSearchPage.tsx | 2 +- frontend/src/pages/tags/TagListPage.tsx | 201 +++++++++++++++++++ frontend/src/types.ts | 9 + 11 files changed, 249 insertions(+), 19 deletions(-) create mode 100644 frontend/src/pages/tags/TagListPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f7c9545..04e14cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import PostNewPage from '@/pages/posts/PostNewPage' import PostSearchPage from '@/pages/posts/PostSearchPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' import SettingPage from '@/pages/users/SettingPage' +import TagListPage from '@/pages/tags/TagListPage' import WikiDetailPage from '@/pages/wiki/WikiDetailPage' import WikiDiffPage from '@/pages/wiki/WikiDiffPage' import WikiEditPage from '@/pages/wiki/WikiEditPage' @@ -46,6 +47,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> + }/> }/> }/> }/> diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index b4be734..1b995a1 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -17,7 +17,7 @@ import SectionTitle from '@/components/common/SectionTitle' import SubsectionTitle from '@/components/common/SubsectionTitle' import SidebarComponent from '@/components/layout/SidebarComponent' import { toast } from '@/components/ui/use-toast' -import { CATEGORIES } from '@/consts' +import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { dateString, originalCreatedAtString } from '@/lib/utils' @@ -255,15 +255,6 @@ export default (({ post }: Props) => { } } - const categoryNames: Record = { - deerjikist: 'ニジラー', - meme: '原作・ネタ元・ミーム等', - character: 'キャラクター', - general: '一般', - material: '素材', - meta: 'メタタグ', - nico: 'ニコニコタグ' } - useEffect (() => { if (!(post)) return @@ -317,7 +308,7 @@ export default (({ post }: Props) => { {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( - {categoryNames[cat]} + {CATEGORY_NAMES[cat]} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 06c30cf..cd7b633 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -74,7 +74,7 @@ export default (({ user }: Props) => { { name: '履歴', to: '/posts/changes' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'タグ', to: '/tags', subMenu: [ - { name: 'タグ一覧', to: '/tags', visible: false }, + { name: 'タグ一覧', to: '/tags', visible: true }, { name: '別名タグ', to: '/tags/aliases', visible: false }, { name: '上位タグ', to: '/tags/implications', visible: false }, { name: 'ニコニコ連携', to: '/tags/nico' }, diff --git a/frontend/src/consts.ts b/frontend/src/consts.ts index 13968d2..368af3a 100644 --- a/frontend/src/consts.ts +++ b/frontend/src/consts.ts @@ -13,6 +13,16 @@ export const CATEGORIES = [ 'nico', ] as const +export const CATEGORY_NAMES: Record = { + deerjikist: 'ニジラー', + meme: '原作・ネタ元・ミーム等', + character: 'キャラクター', + general: '一般', + material: '素材', + meta: 'メタタグ', + nico: 'ニコニコタグ', + } as const + export const FETCH_POSTS_ORDER_FIELDS = [ 'title', 'url', diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 04ec1ba..3570abb 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -5,7 +5,7 @@ import type { FetchPostsParams, Post, PostTagChange } from '@/types' export const fetchPosts = async ( { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, - originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams + originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams, ): Promise<{ posts: Post[] count: number }> => diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index d8cbeef..614810f 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -10,6 +10,7 @@ export const postsKeys = { export const tagsKeys = { root: ['tags'] as const, + index: (p) => ['tags', 'index', p] as const, show: (name: string) => ['tags', name] as const } export const wikiKeys = { diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 3d25684..de0e10b 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -3,6 +3,22 @@ import { apiGet } from '@/lib/api' import type { Tag } from '@/types' +export const fetchTags = async ( + { name, category, postCountGTE, postCountLTE, createdFrom, createdTo, + updatedFrom, updatedTo }, +): Promise<{ tags: Tag[] + count: number }> => + await apiGet ('/tags', { params: { + ...(name && { name }), + ...(category && { category }), + ...(postCountGTE && { post_count_gte: postCountGTE }), + ...(postCountLTE && { post_count_lte: postCountLTE }), + ...(createdFrom && { created_from: createdFrom }), + ...(createdTo && { created_to: createdTo }), + ...(updatedFrom && { updated_from: updatedFrom }), + ...(updatedTo && { updated_to: updatedTo }) } }) + + export const fetchTagByName = async (name: string): Promise => { try { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 0207b5f..aac3785 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -47,16 +47,16 @@ export const originalCreatedAtString = ( if (from.getHours () === 0 && before.getHours () === 0) { - if (Math.abs (diff - 86_400_000) < 60_000) + if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000) return dateString (from, 'hour') + ' (時刻不詳)' if (from.getDate () === 1 && before.getDate () === 1) { - if (2_419_200_000 /* 28 日 */ <= diff && diff < 2_764_800_000 /* 32 日 */) + if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */) return dateString (from, 'day') + ' (日不詳)' if (from.getMonth () === 0 && before.getMonth () === 0 - && (31_536_000_000 /* 365 日 */ <= diff + && (31_449_600_000 /* 364 日 */ <= diff && diff < 31_708_800_000 /* 367 日 */)) return dateString (from, 'month') + ' (月日不詳)' } @@ -65,9 +65,9 @@ export const originalCreatedAtString = ( } const rtn = ([from ? `${ dateString (from, 'second') }` : '', - '~', + '〜', before ? `${ dateString (new Date (before.getTime () - 60_000), 'second') }` : ''] .filter (Boolean) .join (' ')) - return rtn === '~' ? '年月日不詳' : rtn + return rtn === '〜' ? '年月日不詳' : rtn } diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 727dced..057aaf2 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -358,7 +358,7 @@ export default (() => { {results.map (row => ( - + { + const location = useLocation () + const query = useMemo (() => new URLSearchParams (location.search), [location.search]) + + const page = Number (query.get ('page') ?? 1) + const limit = Number (query.get ('limit') ?? 20) + + const qName = query.get ('name') ?? '' + const qCategory = (query.get ('category') || null) as Category | null + const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) + const qPostCountLTE = Number (query.get ('post_count_lte') ?? (-1)) + const qCreatedFrom = query.get ('created_from') ?? '' + const qCreatedTo = query.get ('created_to') ?? '' + const qUpdatedFrom = query.get ('updated_from') ?? '' + const qUpdatedTo = query.get ('updated_to') ?? '' + const order = (query.get ('order') || 'post_count:desc') as FetchTagsOrder + + const [name, setName] = useState ('') + const [category, setCategory] = useState (null) + const [postCountGTE, setPostCountGTE] = useState (1) + const [postCountLTE, setPostCountLTE] = useState (-1) + const [createdFrom, setCreatedFrom] = useState (null) + const [createdTo, setCreatedTo] = useState (null) + const [updatedFrom, setUpdatedFrom] = useState (null) + const [updatedTo, setUpdatedTo] = useState (null) + + const keys = { name: qName, category: qCategory } + const { data, isLoading: loading } = useQuery ({ + queryKey: tagsKeys.index (keys), + queryFn: () => fetchTags (keys) }) + const results = data?.tags ?? [] + + useEffect (() => { + setName (qName) + setCategory (qCategory) + setPostCountGTE (qPostCountGTE) + setPostCountLTE (qPostCountLTE) + setCreatedFrom (qCreatedFrom) + setCreatedTo (qCreatedTo) + setUpdatedFrom (qUpdatedFrom) + setUpdatedTo (qUpdatedTo) + }, []) + + const handleSearch = () => { + ; + } + + return ( + + + タグ | {SITE_TITLE} + + +
+ タグ + +
+ {/* 名前 */} +
+ + setName (e.target.value)} + className="w-full border p-2 rounded"/> +
+ + {/* カテゴリ */} +
+ + +
+ + {/* 広場の投稿数 */} +
+ + setPostCountGTE (Number (e.target.value || (-1)))} + className="border rounded p-2"/> + + setPostCountLTE (Number (e.target.value || (-1)))} + className="border rounded p-2"/> +
+ + {/* はじめて記載された日時 */} +
+ + + + +
+ + {/* 定義の更新日時 */} +
+ + + + +
+ +
+ +
+
+
+ + {loading ? 'Loading...' : (results.length > 0 ? ( +
+
+ + + + + + + + + + + + + + + + + + + + + {results.map (row => ( + + + + + + + ))} + +
+ + + + + + + + + +
+ + {CATEGORY_NAMES[row.category]}{dateString (row.postCount)}{dateString (row.createdAt)}{dateString (row.updatedAt)}
+
+
) : '結果ないよ(笑)')} +
) +}) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f40f533..da7eb04 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -26,6 +26,15 @@ export type FetchPostsParams = { limit: number order: FetchPostsOrder } +export type FetchTagsOrder = `${ FetchTagsOrderField }:${ 'asc' | 'desc' }` + +export type FetchTagsOrderField = + | 'name' + | 'category' + | 'post_count' + | 'create_at' + | 'updated_at' + export type Menu = MenuItem[] export type MenuItem = { -- 2.34.1 From c24ffad7dd509e60d450a020bbce856c59951415 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 02:13:45 +0900 Subject: [PATCH 03/13] #61 --- backend/app/controllers/posts_controller.rb | 2 +- backend/app/controllers/tags_controller.rb | 63 +++++++++- backend/app/representations/tag_repr.rb | 3 +- frontend/src/components/SortHeader.tsx | 31 +++++ frontend/src/components/common/Pagination.tsx | 28 +++-- frontend/src/lib/queryKeys.ts | 8 +- frontend/src/lib/tags.ts | 16 ++- frontend/src/pages/posts/PostSearchPage.tsx | 61 +++++----- frontend/src/pages/tags/TagListPage.tsx | 113 ++++++++++++++---- frontend/src/types.ts | 18 ++- 10 files changed, 269 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/SortHeader.tsx diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 0b99f73..980a2e9 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -75,7 +75,7 @@ class PostsController < ApplicationController else "posts.#{ order[0] }" end - posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, id #{ order[1] }")) + posts = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, posts.id #{ order[1] }")) .limit(limit) .offset(offset) .to_a diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index b97fcaa..9652f18 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -2,18 +2,71 @@ class TagsController < ApplicationController def index post_id = params[:post] - tags = + name = params[:name].presence + category = params[:category].presence + post_count_between = (params[:post_count_gte].presence || -1).to_i, + (params[:post_count_lte].presence || -1).to_i + post_count_between[0] = nil if post_count_between[0] < 0 + post_count_between[1] = nil if post_count_between[1] < 0 + created_between = params[:created_from].presence, params[:created_to].presence + updated_between = params[:updated_from].presence, params[:updated_to].presence + + order = params[:order].to_s.split(':', 2).map(&:strip) + unless order[0].in?(['name', 'category', 'post_count', 'created_at', 'updated_at']) + order[0] = 'post_count' + end + unless order[1].in?(['asc', 'desc']) + order[1] = order[0].in?(['name', 'category']) ? 'asc' : 'desc' + end + + page = (params[:page].presence || 1).to_i + limit = (params[:limit].presence || 20).to_i + + page = 1 if page < 1 + limit = 1 if limit < 1 + + offset = (page - 1) * limit + + q = if post_id.present? Tag.joins(:posts, :tag_name) else Tag.joins(:tag_name) end .includes(:tag_name, tag_name: :wiki_page) - if post_id.present? - tags = tags.where(posts: { id: post_id }) - end + q = q.where(posts: { id: post_id }) if post_id.present? + + q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name + q = q.where(category: category) if category + q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] + q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] + q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] + q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1] + q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0] + q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1] + + sort_sql = + case order[0] + when 'name' + 'tag_names.name' + when 'category' + 'CASE tags.category ' + + "WHEN 'deerjikist' THEN 0 " + + "WHEN 'meme' THEN 1 " + + "WHEN 'character' THEN 2 " + + "WHEN 'general' THEN 3 " + + "WHEN 'material' THEN 4 " + + "WHEN 'meta' THEN 5 " + + "WHEN 'nico' THEN 6 END" + else + "tags.#{ order[0] }" + end + tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }")) + .limit(limit) + .offset(offset) + .to_a - render json: TagRepr.base(tags) + render json: { tags: TagRepr.base(tags), count: q.size } end def autocomplete diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index cb2c470..db8b6eb 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -2,7 +2,8 @@ module TagRepr - BASE = { only: [:id, :category, :post_count], methods: [:name, :has_wiki] }.freeze + BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], + methods: [:name, :has_wiki] }.freeze module_function diff --git a/frontend/src/components/SortHeader.tsx b/frontend/src/components/SortHeader.tsx new file mode 100644 index 0000000..35239af --- /dev/null +++ b/frontend/src/components/SortHeader.tsx @@ -0,0 +1,31 @@ +import { useLocation } from 'react-router-dom' + +import PrefetchLink from '@/components/PrefetchLink' + + +export default ({ by, label, currentOrder, defaultDirection }: { + by: T + label: string + currentOrder: `${ T }:${ 'asc' | 'desc' }` + defaultDirection: Record }) => { + const [fld, dir] = currentOrder.split (':') + + const location = useLocation () + const qs = new URLSearchParams (location.search) + const nextDir = + (by === fld) + ? (dir === 'asc' ? 'desc' : 'asc') + : (defaultDirection[by] || 'desc') + qs.set ('order', `${ by }:${ nextDir }`) + qs.set ('page', '1') + + return ( + + + {label} + {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} + + ) +} diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx index fc7dbde..53d045d 100644 --- a/frontend/src/components/common/Pagination.tsx +++ b/frontend/src/components/common/Pagination.tsx @@ -48,7 +48,7 @@ const getPages = ( } -export default (({ page, totalPages, siblingCount = 4 }) => { +export default (({ page, totalPages, siblingCount = 2 }) => { const location = useLocation () const buildTo = (p: number) => { @@ -63,19 +63,31 @@ export default (({ page, totalPages, siblingCount = 4 }) => { ) }) satisfies FC diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 614810f..b0d42f2 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -1,4 +1,4 @@ -import type { FetchPostsParams } from '@/types' +import type { FetchPostsParams, FetchTagsParams } from '@/types' export const postsKeys = { root: ['posts'] as const, @@ -9,9 +9,9 @@ export const postsKeys = { ['posts', 'changes', p] as const } export const tagsKeys = { - root: ['tags'] as const, - index: (p) => ['tags', 'index', p] as const, - show: (name: string) => ['tags', name] as const } + root: ['tags'] as const, + index: (p: FetchTagsParams) => ['tags', 'index', p] as const, + show: (name: string) => ['tags', name] as const } export const wikiKeys = { root: ['wiki'] as const, diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index de0e10b..2935934 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -1,22 +1,26 @@ import { apiGet } from '@/lib/api' -import type { Tag } from '@/types' +import type { FetchTagsParams, Tag } from '@/types' export const fetchTags = async ( - { name, category, postCountGTE, postCountLTE, createdFrom, createdTo, - updatedFrom, updatedTo }, + { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, + updatedFrom, updatedTo, page, limit, order }: FetchTagsParams, ): Promise<{ tags: Tag[] count: number }> => await apiGet ('/tags', { params: { + ...(post != null && { post }), ...(name && { name }), ...(category && { category }), - ...(postCountGTE && { post_count_gte: postCountGTE }), - ...(postCountLTE && { post_count_lte: postCountLTE }), + ...(postCountGTE != null && { post_count_gte: postCountGTE }), + ...(postCountLTE != null && { post_count_lte: postCountLTE }), ...(createdFrom && { created_from: createdFrom }), ...(createdTo && { created_to: createdTo }), ...(updatedFrom && { updated_from: updatedFrom }), - ...(updatedTo && { updated_to: updatedTo }) } }) + ...(updatedTo && { updated_to: updatedTo }), + ...(page && { page }), + ...(limit && { limit }), + ...(order && { order }) } }) export const fetchTagByName = async (name: string): Promise => { diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 057aaf2..a824953 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -5,6 +5,7 @@ import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' import PrefetchLink from '@/components/PrefetchLink' +import SortHeader from '@/components/SortHeader' import TagLink from '@/components/TagLink' import TagSearchBox from '@/components/TagSearchBox' import DateTimeField from '@/components/common/DateTimeField' @@ -102,28 +103,6 @@ export default (() => { document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) }, [location.search]) - const SortHeader = ({ by, label }: { by: FetchPostsOrderField; label: string }) => { - const [fld, dir] = order.split (':') - - const qs = new URLSearchParams (location.search) - const nextDir = - (by === fld) - ? (dir === 'asc' ? 'desc' : 'asc') - : (['title', 'url'].includes (by) ? 'asc' : 'desc') - qs.set ('order', `${ by }:${ nextDir }`) - qs.set ('page', '1') - - return ( - - - {label} - {by === fld && (dir === 'asc' ? ' ▲' : ' ▼')} - - ) - } - // TODO: TagSearch からのコピペのため,共通化を考へる. const whenChanged = async (ev: ChangeEvent) => { setTagsStr (ev.target.value) @@ -188,7 +167,7 @@ export default (() => { setIf (qs, 'updated_from', updatedFrom) setIf (qs, 'updated_to', updatedTo) qs.set ('match', matchType) - qs.set ('page', String ('1')) + qs.set ('page', '1') qs.set ('order', order) navigate (`${ location.pathname }?${ qs.toString () }`) } @@ -207,6 +186,12 @@ export default (() => { search () } + const defaultDirection = { title: 'asc', + url: 'asc', + original_created_at: 'desc', + created_at: 'desc', + updated_at: 'desc' } as const + return ( @@ -339,20 +324,40 @@ export default (() => { 投稿 - + + by="title" + label="タイトル" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="url" + label="URL" + currentOrder={order} + defaultDirection={defaultDirection}/> タグ - + + by="original_created_at" + label="オリジナルの投稿日時" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="created_at" + label="投稿日時" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="updated_at" + label="更新日時" + currentOrder={order} + defaultDirection={defaultDirection}/> diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index ccf15ce..9059a30 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -1,23 +1,38 @@ import { useQuery } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' -import { useLocation } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' +import SortHeader from '@/components/SortHeader' +import TagLink from '@/components/TagLink' import DateTimeField from '@/components/common/DateTimeField' import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' +import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { tagsKeys } from '@/lib/queryKeys' +import { fetchTags } from '@/lib/tags' +import { dateString } from '@/lib/utils' -import type { FC } from 'react' +import type { FC, FormEvent } from 'react' -import type { Category, FetchTagsOrder } from '@/types' +import type { Category, FetchTagsOrder, FetchTagsOrderField } from '@/types' + + +const setIf = (qs: URLSearchParams, k: string, v: string | null) => { + const t = v?.trim () + if (t) + qs.set (k, t) +} export default (() => { const location = useLocation () + + const navigate = useNavigate () + const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const page = Number (query.get ('page') ?? 1) @@ -26,7 +41,8 @@ export default (() => { const qName = query.get ('name') ?? '' const qCategory = (query.get ('category') || null) as Category | null const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) - const qPostCountLTE = Number (query.get ('post_count_lte') ?? (-1)) + const qPostCountLTE = + query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null const qCreatedFrom = query.get ('created_from') ?? '' const qCreatedTo = query.get ('created_to') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? '' @@ -36,17 +52,28 @@ export default (() => { const [name, setName] = useState ('') const [category, setCategory] = useState (null) const [postCountGTE, setPostCountGTE] = useState (1) - const [postCountLTE, setPostCountLTE] = useState (-1) + const [postCountLTE, setPostCountLTE] = useState (null) const [createdFrom, setCreatedFrom] = useState (null) const [createdTo, setCreatedTo] = useState (null) const [updatedFrom, setUpdatedFrom] = useState (null) const [updatedTo, setUpdatedTo] = useState (null) - const keys = { name: qName, category: qCategory } + const keys = { + page, limit, order, + post: null, + name: qName, + category: qCategory, + postCountGTE: qPostCountGTE, + postCountLTE: qPostCountLTE, + createdFrom: qCreatedFrom, + createdTo: qCreatedTo, + updatedFrom: qUpdatedFrom, + updatedTo: qUpdatedTo } const { data, isLoading: loading } = useQuery ({ queryKey: tagsKeys.index (keys), queryFn: () => fetchTags (keys) }) const results = data?.tags ?? [] + const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { setName (qName) @@ -57,12 +84,36 @@ export default (() => { setCreatedTo (qCreatedTo) setUpdatedFrom (qUpdatedFrom) setUpdatedTo (qUpdatedTo) - }, []) - const handleSearch = () => { - ; + document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) + }, [location.search]) + + const handleSearch = (e: FormEvent) => { + e.preventDefault () + + const qs = new URLSearchParams () + setIf (qs, 'name', name) + setIf (qs, 'category', category) + if (postCountGTE !== 1) + qs.set ('post_count_gte', String (postCountGTE)) + if (postCountLTE != null) + qs.set ('post_count_lte', String (postCountLTE)) + setIf (qs, 'created_from', createdFrom) + setIf (qs, 'created_to', createdTo) + setIf (qs, 'updated_from', updatedFrom) + setIf (qs, 'updated_to', updatedTo) + qs.set ('page', '1') + qs.set ('order', order) + + navigate (`${ location.pathname }?${ qs.toString () }`) } + const defaultDirection = { name: 'asc', + category: 'asc', + post_count: 'desc', + created_at: 'desc', + updated_at: 'desc' } as const + return ( @@ -88,7 +139,7 @@ export default (() => { setPostCountGTE (Number (e.target.value || (-1)))} + value={postCountGTE < 0 ? 0 : String (postCountGTE)} + onChange={e => setPostCountGTE (Number (e.target.value || 0))} className="border rounded p-2"/> setPostCountLTE (Number (e.target.value || (-1)))} + value={postCountLTE == null ? '' : String (postCountLTE)} + onChange={e => setPostCountLTE (e.target.value ? Number (e.target.value) : null)} className="border rounded p-2"/> @@ -165,19 +216,39 @@ export default (() => { - + + by="name" + label="タグ" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="category" + label="カテゴリ" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="post_count" + label="件数" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="created_at" + label="最初の記載日時" + currentOrder={order} + defaultDirection={defaultDirection}/> - + + by="updated_at" + label="更新日時" + currentOrder={order} + defaultDirection={defaultDirection}/> @@ -189,13 +260,15 @@ export default (() => { {CATEGORY_NAMES[row.category]} - {dateString (row.postCount)} + {row.postCount} {dateString (row.createdAt)} {dateString (row.updatedAt)} ))} + + ) : '結果ないよ(笑)')} ) }) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da7eb04..4dc8af5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -32,9 +32,23 @@ export type FetchTagsOrderField = | 'name' | 'category' | 'post_count' - | 'create_at' + | 'created_at' | 'updated_at' +export type FetchTagsParams = { + post: number | null + name: string + category: Category | null + postCountGTE: number + postCountLTE: number | null + createdFrom: string + createdTo: string + updatedFrom: string + updatedTo: string + page: number + limit: number + order: FetchTagsOrder } + export type Menu = MenuItem[] export type MenuItem = { @@ -80,6 +94,8 @@ export type Tag = { name: string category: Category postCount: number + createdAt: string + updatedAt: string hasWiki: boolean children?: Tag[] matchedAlias?: string | null } -- 2.34.1 From ea61f4a0478919d16406b57d4e6777b2f7112101 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 02:26:24 +0900 Subject: [PATCH 04/13] #61 --- frontend/src/lib/prefetchers.ts | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 4461ae7..0d4f2d8 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -3,12 +3,12 @@ import { match } from 'path-to-regexp' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName } from '@/lib/tags' +import { fetchTagByName, fetchTags } from '@/lib/tags' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' -import type { FetchPostsOrder } from '@/types' +import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' type Prefetcher = (qc: QueryClient, url: URL) => Promise @@ -131,6 +131,32 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => { } +const prefetchTagsIndex: Prefetcher = async (qc, url) => { + const postRaw = url.searchParams.get ('post') + const post = postRaw ? Number (postRaw) : null + const name = url.searchParams.get ('name') ?? '' + const category = (url.searchParams.get ('category') || null) as Category | null + const postCountGTE = Number (url.searchParams.get ('post_count_gte') || 1) + const postCountLTERaw = url.searchParams.get ('post_count_lte') + const postCountLTE = postCountLTERaw ? Number (postCountLTERaw) : null + const createdFrom = url.searchParams.get ('created_from') ?? '' + const createdTo = url.searchParams.get ('created_to') ?? '' + const updatedFrom = url.searchParams.get ('updated_from') ?? '' + const updatedTo = url.searchParams.get ('updated_to') ?? '' + const page = Number (url.searchParams.get ('page') || 1) + const limit = Number (url.searchParams.get ('limit') || 20) + const order = (url.searchParams.get ('order') ?? 'post_count:desc') as FetchTagsOrder + + const keys = { + post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, + updatedFrom, updatedTo, page, limit, order } + + await qc.prefetchQuery ({ + queryKey: tagsKeys.index (keys), + queryFn: () => fetchTags (keys) }) +} + + export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ { test: u => ['/', '/posts', '/posts/search'].includes (u.pathname), run: prefetchPostsIndex }, @@ -141,7 +167,8 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] { test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex }, { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) && Boolean (mWiki (u.pathname))), - run: prefetchWikiPageShow }] + run: prefetchWikiPageShow }, + { test: u => u.pathname === '/tags', run: prefetchTagsIndex }] export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise => { -- 2.34.1 From 1b56176cacd6d779d17e5b0fc92a8fb762c1bc3c Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 02:33:28 +0900 Subject: [PATCH 05/13] #61 --- frontend/scripts/generate-sitemap.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/scripts/generate-sitemap.js b/frontend/scripts/generate-sitemap.js index bf6c9fe..774f415 100644 --- a/frontend/scripts/generate-sitemap.js +++ b/frontend/scripts/generate-sitemap.js @@ -20,7 +20,7 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`, match: 'all', limit: '20' }) } })).data.posts -const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data +const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data.tags const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) const fetchWikiPages = async () => (await axios.get (`${ API_BASE_URL }/wiki`)).data -- 2.34.1 From 5581d6e1ccddae1398998494e0885a46bc5c093e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 02:48:13 +0900 Subject: [PATCH 06/13] #61 --- backend/spec/requests/tags_spec.rb | 274 +++++++++++++++++++++++------ 1 file changed, 219 insertions(+), 55 deletions(-) diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 70309b7..8dfa51a 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -11,16 +11,180 @@ RSpec.describe 'Tags API', type: :request do let!(:tn2) { TagName.create!(name: 'unknown') } let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) } + def response_tags + json.fetch('tags') + end + + def response_names + response_tags.map { |t| t.fetch('name') } + end + describe 'GET /tags' do - it 'returns tags with name' do + it 'returns tags with count and metadata' do get '/tags' expect(response).to have_http_status(:ok) + expect(json).to include('tags', 'count') + expect(response_tags).to be_an(Array) + expect(json['count']).to be_an(Integer) + expect(json['count']).to be >= response_tags.size + + row = response_tags.find { |t| t['name'] == 'spec_tag' } + expect(row).to include( + 'id' => tag.id, + 'name' => 'spec_tag', + 'category' => 'general', + 'post_count' => 1, + 'has_wiki' => false) + expect(row).to have_key('created_at') + expect(row).to have_key('updated_at') + end - expect(json).to be_an(Array) - expect(json).not_to be_empty - expect(json[0]).to have_key('name') - expect(json.map { |t| t['name'] }).to include('spec_tag') + it 'filters tags by post id' do + get '/tags', params: { post: post.id } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(1) + expect(response_names).to eq(['spec_tag']) + end + + it 'filters tags by partial name' do + get '/tags', params: { name: 'spec' } + + expect(response).to have_http_status(:ok) + expect(response_names).to include('spec_tag') + expect(response_names).not_to include('unknown') + end + + it 'filters tags by category' do + meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme) + + get '/tags', params: { category: 'meme' } + + expect(response).to have_http_status(:ok) + expect(response_names).to eq(['meme_only']) + expect(response_tags.first['id']).to eq(meme.id) + end + + it 'filters tags by post_count range' do + low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) + mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general) + high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general) + + low.update_columns(post_count: 1) + mid.update_columns(post_count: 3) + high.update_columns(post_count: 5) + + get '/tags', params: { + name: 'pc_', + post_count_gte: 2, + post_count_lte: 4, + order: 'post_count:asc', + } + + expect(response).to have_http_status(:ok) + expect(response_names).to eq(['pc_mid']) + end + + it 'filters tags by created_at range' do + old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general) + new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general) + + old_time = Time.zone.local(2024, 1, 1, 0, 0, 0) + new_time = Time.zone.local(2024, 2, 1, 0, 0, 0) + + old_tag.update_columns(created_at: old_time, updated_at: old_time) + new_tag.update_columns(created_at: new_time, updated_at: new_time) + + get '/tags', params: { + name: 'created_', + created_from: Time.zone.local(2024, 1, 15, 0, 0, 0).iso8601, + order: 'created_at:asc', + } + + expect(response).to have_http_status(:ok) + expect(response_names).to eq(['created_new']) + end + + it 'filters tags by updated_at range' do + old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general) + new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general) + + old_time = Time.zone.local(2024, 3, 1, 0, 0, 0) + new_time = Time.zone.local(2024, 4, 1, 0, 0, 0) + + old_tag.update_columns(created_at: old_time, updated_at: old_time) + new_tag.update_columns(created_at: new_time, updated_at: new_time) + + get '/tags', params: { + name: 'updated_', + updated_to: Time.zone.local(2024, 3, 15, 0, 0, 0).iso8601, + order: 'updated_at:asc', + } + + expect(response).to have_http_status(:ok) + expect(response_names).to eq(['updated_old']) + end + + it 'orders tags by custom category order' do + Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist) + Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme) + Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character) + Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) + Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) + Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) + Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico) + + get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } + + expect(response).to have_http_status(:ok) + expect(response_names).to eq(%w[ + cat_deerjikist + cat_meme + cat_character + cat_general + cat_material + cat_meta + nico:cat_nico + ]) + end + + it 'paginates and keeps total count' do + %w[pag_a pag_b pag_c].each do |name| + Tag.create!(tag_name: TagName.create!(name:), category: :general) + end + + get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(3) + expect(response_names).to eq(%w[pag_c]) + end + + it 'falls back to default ordering when order is invalid' do + low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general) + high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general) + + low.update_columns(post_count: 1) + high.update_columns(post_count: 9) + + get '/tags', params: { name: 'fallback_', order: 'nope:sideways' } + + expect(response).to have_http_status(:ok) + expect(response_names.first).to eq('fallback_high') + end + + it 'normalises invalid page and limit' do + %w[norm_a norm_b].each do |name| + Tag.create!(tag_name: TagName.create!(name:), category: :general) + end + + get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 } + + expect(response).to have_http_status(:ok) + expect(json['count']).to eq(2) + expect(response_tags.size).to eq(1) + expect(response_names).to eq(['norm_a']) end end @@ -37,9 +201,13 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) expect(json).to include( - 'id' => tag.id, - 'name' => 'spec_tag', - 'category' => 'general') + 'id' => tag.id, + 'name' => 'spec_tag', + 'category' => 'general', + 'post_count' => 1, + 'has_wiki' => false) + expect(json).to have_key('created_at') + expect(json).to have_key('updated_at') end end @@ -61,7 +229,7 @@ RSpec.describe 'Tags API', type: :request do expect(json).to be_an(Array) expect(json.map { |t| t['name'] }).to include('spec_tag') - t = json.find { |t| t['name'] == 'spec_tag' } + t = json.find { |x| x['name'] == 'spec_tag' } expect(t).to have_key('matched_alias') expect(t['matched_alias']).to be(nil) end @@ -73,9 +241,9 @@ RSpec.describe 'Tags API', type: :request do expect(json).to be_an(Array) expect(json.map { |t| t['name'] }).to include('spec_tag') - t = json.find { |t| t['name'] == 'spec_tag' } + t = json.find { |x| x['name'] == 'spec_tag' } expect(t['matched_alias']).to eq('unko') - expect(json.map { |t| t['name'] }).not_to include('unknown') + expect(json.map { |x| x['name'] }).not_to include('unknown') end end @@ -85,10 +253,14 @@ RSpec.describe 'Tags API', type: :request do expect(response).to have_http_status(:ok) - expect(json).to have_key('id') - expect(json).to have_key('name') - expect(json['id']).to eq(tag.id) - expect(json['name']).to eq('spec_tag') + expect(json).to include( + 'id' => tag.id, + 'name' => 'spec_tag', + 'category' => 'general', + 'post_count' => 1, + 'has_wiki' => false) + expect(json).to have_key('created_at') + expect(json).to have_key('updated_at') end it 'returns 404 when not found' do @@ -97,7 +269,6 @@ RSpec.describe 'Tags API', type: :request do end end - # member? を持つ user を想定(Factory 側で trait 作ってもOK) let(:member_user) { create(:user) } let(:non_member_user) { create(:user) } @@ -110,87 +281,80 @@ RSpec.describe 'Tags API', type: :request do allow(non_member_user).to receive(:gte_member?).and_return(false) end - describe "PATCH /tags/:id" do - context "未ログイン" do + describe 'PATCH /tags/:id' do + context '未ログイン' do before { stub_current_user(nil) } - it "401 を返す" do - patch "/tags/#{tag.id}", params: { name: "new" } + it '401 を返す' do + patch "/tags/#{ tag.id }", params: { name: 'new' } expect(response).to have_http_status(:unauthorized) end end - context "ログインしてゐるが member でない" do + context 'ログインしてゐるが member でない' do before { stub_current_user(non_member_user) } - it "403 を返す" do - patch "/tags/#{tag.id}", params: { name: "new" } + it '403 を返す' do + patch "/tags/#{ tag.id }", params: { name: 'new' } expect(response).to have_http_status(:forbidden) end end - context "member" do + context 'member' do before { stub_current_user(member_user) } - it "name だけ更新できる" do - patch "/tags/#{tag.id}", params: { name: "new" } + it 'name だけ更新できる' do + patch "/tags/#{ tag.id }", params: { name: 'new' } expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq("new") - expect(tag.category).to eq("general") + expect(tag.name).to eq('new') + expect(tag.category).to eq('general') - json = JSON.parse(response.body) - expect(json["id"]).to eq(tag.id) - expect(json["name"]).to eq("new") - expect(json["category"]).to eq("general") + body = JSON.parse(response.body) + expect(body['id']).to eq(tag.id) + expect(body['name']).to eq('new') + expect(body['category']).to eq('general') end - it "category だけ更新できる" do - patch "/tags/#{tag.id}", params: { category: "meme" } + it 'category だけ更新できる' do + patch "/tags/#{ tag.id }", params: { category: 'meme' } expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq("spec_tag") - expect(tag.category).to eq("meme") + expect(tag.name).to eq('spec_tag') + expect(tag.category).to eq('meme') end - it "空文字は presence により無視され、更新は走らない(値が変わらない)" do - patch "/tags/#{tag.id}", params: { name: "", category: " " } + it '空文字は presence により無視され、更新は走らない(値が変わらない)' do + patch "/tags/#{ tag.id }", params: { name: '', category: ' ' } expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq("spec_tag") - expect(tag.category).to eq("general") + expect(tag.name).to eq('spec_tag') + expect(tag.category).to eq('general') end - it "両方更新できる" do - patch "/tags/#{tag.id}", params: { name: "n", category: "meta" } + it '両方更新できる' do + patch "/tags/#{ tag.id }", params: { name: 'n', category: 'meta' } expect(response).to have_http_status(:ok) tag.reload - expect(tag.name).to eq("n") - expect(tag.category).to eq("meta") + expect(tag.name).to eq('n') + expect(tag.category).to eq('meta') end - it "存在しない id だと RecordNotFound になる(通常は 404)" do - # Rails 設定次第で例外がそのまま上がる/404になる - # APIなら rescue_from で 404 にしてることが多いので、その場合は 404 を期待。 - patch "/tags/999999999", params: { name: "x" } - + it '存在しない id だと RecordNotFound になる(通常は 404)' do + patch '/tags/999999999', params: { name: 'x' } expect(response.status).to be_in([404, 500]) end - it "バリデーションで update! が失敗したら(通常は 422 か 500)" do - patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } - - # rescue_from の実装次第で変はる: - # - RecordInvalid を 422 にしてるなら 422 - # - 未処理なら 500 + it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do + patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } expect(response.status).to be_in([422, 500]) end end -- 2.34.1 From be14ae3ee40108a2bfb831a8080549359a5c9d82 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 15:23:07 +0900 Subject: [PATCH 07/13] #61 --- backend/app/controllers/posts_controller.rb | 15 +- backend/app/representations/post_repr.rb | 14 +- backend/app/representations/user_repr.rb | 16 ++ .../components/DraggableDroppableTagRow.tsx | 10 +- frontend/src/components/TagDetailSidebar.tsx | 170 +++++++++--------- frontend/src/components/TagSidebar.tsx | 47 ++--- frontend/src/components/common/Pagination.tsx | 68 +++++-- frontend/src/pages/posts/PostDetailPage.tsx | 4 +- frontend/src/pages/tags/TagListPage.tsx | 4 +- frontend/src/types.ts | 3 +- 10 files changed, 200 insertions(+), 151 deletions(-) create mode 100644 backend/app/representations/user_repr.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 980a2e9..2bf91cf 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -100,23 +100,16 @@ class PostsController < ApplicationController .first return head :not_found unless post - viewed = current_user&.viewed?(post) || false - - render json: PostRepr.base(post).merge(viewed:) + render json: PostRepr.base(post, current_user) end def show post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id]) return head :not_found unless post - viewed = current_user&.viewed?(post) || false - - json = post.as_json - json['tags'] = build_tag_tree_for(post.tags) - json['related'] = post.related(limit: 20) - json['viewed'] = viewed - - render json: + render json: PostRepr.base(post, current_user) + .merge(tags: build_tag_tree_for(post.tags), + related: post.related(limit: 20)) end def create diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 438ccc1..2dc444f 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -2,15 +2,19 @@ module PostRepr - BASE = { include: { tags: TagRepr::BASE } }.freeze + BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze module_function - def base post - post.as_json(BASE) + def base post, current_user = nil + json = post.as_json(BASE) + return json unless current_user + + viewed = current_user.viewed?(post) + json.merge(viewed:) end - def many posts - posts.map { |p| base(p) } + def many posts, current_user = nil + posts.map { |p| base(p, current_user) } end end diff --git a/backend/app/representations/user_repr.rb b/backend/app/representations/user_repr.rb new file mode 100644 index 0000000..bbb7bec --- /dev/null +++ b/backend/app/representations/user_repr.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + + +module UserRepr + BASE = { only: [:id, :name] }.freeze + + module_function + + def base user + user.as_json(BASE) + end + + def many users + users.map { |u| base(u) } + end +end diff --git a/frontend/src/components/DraggableDroppableTagRow.tsx b/frontend/src/components/DraggableDroppableTagRow.tsx index af8d390..6660f7e 100644 --- a/frontend/src/components/DraggableDroppableTagRow.tsx +++ b/frontend/src/components/DraggableDroppableTagRow.tsx @@ -1,5 +1,6 @@ import { useDraggable, useDroppable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' +import { motion } from 'framer-motion' import { useRef } from 'react' import TagLink from '@/components/TagLink' @@ -14,10 +15,11 @@ type Props = { nestLevel: number pathKey: string parentTagId?: number - suppressClickRef: MutableRefObject } + suppressClickRef: MutableRefObject + sp?: boolean } -export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Props) => { +export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => { const dndId = `tag-node:${ pathKey }` const downPosRef = useRef<{ x: number; y: number } | null> (null) @@ -88,6 +90,8 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef }: Pro className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')} {...attributes} {...listeners}> - + + + ) }) satisfies FC diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 1b995a1..8579442 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -6,8 +6,8 @@ import { DndContext, useSensor, useSensors } from '@dnd-kit/core' import { restrictToWindowEdges } from '@dnd-kit/modifiers' -import { AnimatePresence, motion } from 'framer-motion' -import { useEffect, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { useEffect, useMemo, useRef, useState } from 'react' import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' import PrefetchLink from '@/components/PrefetchLink' @@ -35,28 +35,27 @@ const renderTagTree = ( path: string, suppressClickRef: MutableRefObject, parentTagId?: number, + sp?: boolean, ): ReactNode[] => { const key = `${ path }-${ tag.id }` const self = ( - +
  • - ) + suppressClickRef={suppressClickRef} + sp={sp}/> +
  • ) return [ self, ...((tag.children ?.sort ((a, b) => a.name < b.name ? -1 : 1) - .flatMap (child => renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id))) + .flatMap (child => + renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp))) ?? [])] } @@ -147,14 +146,32 @@ const DropSlot = ({ cat }: { cat: Category }) => { } -type Props = { post: Post | null } +type Props = { post: Post; sp?: boolean } + +export default (({ post, sp }: Props) => { + sp = Boolean (sp) + + const baseTags = useMemo (() => { + 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) + + return tagsTmp + }, [post]) -export default (({ post }: Props) => { const [activeTagId, setActiveTagId] = useState (null) const [dragging, setDragging] = useState (false) const [saving, setSaving] = useState (false) - const [tags, setTags] = useState ({ } as TagByCategory) + const [tags, setTags] = useState (baseTags) const suppressClickRef = useRef (false) @@ -163,9 +180,6 @@ export default (({ post }: Props) => { useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } })) const reloadTags = async (): Promise => { - if (!(post)) - return - setTags (buildTagByCategory (await apiGet (`/posts/${ post.id }`))) } @@ -256,23 +270,8 @@ export default (({ post }: Props) => { } useEffect (() => { - if (!(post)) - return - - 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]) + setTags (baseTags) + }, [baseTags]) return ( @@ -305,60 +304,57 @@ export default (({ post }: Props) => { document.body.style.userSelect = '' }} modifiers={[restrictToWindowEdges]}> - - {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( - - {CATEGORY_NAMES[cat]} - - - - {(tags[cat] ?? []).flatMap (tag => ( - renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined)))} - - - - ))} - {post && ( -
    - 情報 -
      -
    • Id.: {post.id}
    • - {/* TODO: uploadedUser の取得を対応したらコメント外す */} - {/* -
    • - <>耕作者: - {post.uploadedUser - ? ( - - {post.uploadedUser.name || '名もなきニジラー'} - ) - : 'bot操作'} -
    • - */} -
    • 耕作日時: {dateString (post.createdAt)}
    • -
    • - <>リンク: - - {post.url} - -
    • -
    • - <>オリジナルの投稿日時: - {originalCreatedAtString (post.originalCreatedFrom, - post.originalCreatedBefore)} -
    • -
    • - - 履歴 - -
    • -
    -
    )} -
    + {CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && ( +
    + + + {CATEGORY_NAMES[cat]} + + + +
      + {(tags[cat] ?? []).flatMap (tag => ( + renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} + +
    +
    ))} + {post && ( + + 情報 +
      +
    • Id.: {post.id}
    • +
    • + <>耕作者: + {post.uploadedUser + ? ( + + {post.uploadedUser.name || '名もなきニジラー'} + ) + : 'bot操作'} +
    • +
    • 耕作日時: {dateString (post.createdAt)}
    • +
    • + <>リンク: + + {post.url} + +
    • +
    • + <>オリジナルの投稿日時: + {originalCreatedAtString (post.originalCreatedFrom, + post.originalCreatedBefore)} +
    • +
    • + + 履歴 + +
    • +
    +
    )}
    diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index ae06196..a91966e 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -65,30 +65,31 @@ export default (({ posts, onClick }: Props) => { {CATEGORIES.flatMap (cat => cat in tags ? ( tags[cat].map (tag => (
  • - + + +
  • ))) : [])} 関聯 - {posts.length > 0 && ( - { - ev.preventDefault () - void ((async () => { - try - { - const data = await apiGet ('/posts/random', - { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), - match: (anyFlg ? 'any' : 'all') } }) - navigate (`/posts/${ data.id }`) - } - catch - { - ; - } - }) ()) - }}> - ランダム - )} + { + ev.preventDefault () + void ((async () => { + try + { + const data = await apiGet ('/posts/random', + { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), + match: (anyFlg ? 'any' : 'all') } }) + navigate (`/posts/${ data.id }`) + } + catch + { + ; + } + }) ()) + }}> + ランダム + ) return ( @@ -96,7 +97,7 @@ export default (({ posts, onClick }: Props) => {
    - {TagBlock} + {posts.length > 0 && TagBlock}
    @@ -112,7 +113,7 @@ export default (({ posts, onClick }: Props) => { animate="visible" exit="hidden" transition={{ duration: .2, ease: 'easeOut' }}> - {TagBlock} + {posts.length > 0 && TagBlock} )} diff --git a/frontend/src/components/common/Pagination.tsx b/frontend/src/components/common/Pagination.tsx index 53d045d..8e1b365 100644 --- a/frontend/src/components/common/Pagination.tsx +++ b/frontend/src/components/common/Pagination.tsx @@ -48,7 +48,7 @@ const getPages = ( } -export default (({ page, totalPages, siblingCount = 2 }) => { +export default (({ page, totalPages, siblingCount = 3 }) => { const location = useLocation () const buildTo = (p: number) => { @@ -64,30 +64,64 @@ export default (({ page, totalPages, siblingCount = 2 }) => {
    {(page > 1) ? ( - - < - ) - : <} + <> + + |< + + + < + + ) + : ( + <> + + |< + + + < + + )} {pages.map ((p, idx) => ( (p === '…') - ? + ? : ((p === page) ? {p} - : {p})))} + : ( + + {p} + ))))} {(page < totalPages) ? ( - - > - ) - : >} + <> + + > + + + >| + + ) + : ( + <> + > + >| + )}
    ) }) satisfies FC diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 828e09e..4c11c62 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -99,7 +99,7 @@ export default (({ user }: Props) => {
    - + {post && }
    @@ -149,7 +149,7 @@ export default (({ user }: Props) => {
    - + {post && }
    ) }) satisfies FC diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index 9059a30..fd9dc60 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -41,8 +41,8 @@ export default (() => { const qName = query.get ('name') ?? '' const qCategory = (query.get ('category') || null) as Category | null const qPostCountGTE = Number (query.get ('post_count_gte') ?? 1) - const qPostCountLTE = - query.get ('post_count_lte') ? Number (query.get ('post_count_lte')) : null + const qPostCountLTERaw = query.get ('post_count_lte') + const qPostCountLTE = qPostCountLTERaw ? Number (qPostCountLTERaw) : null const qCreatedFrom = query.get ('created_from') ?? '' const qCreatedTo = query.get ('created_to') ?? '' const qUpdatedFrom = query.get ('updated_from') ?? '' diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4dc8af5..441edf4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -73,7 +73,8 @@ export type Post = { originalCreatedFrom: string | null originalCreatedBefore: string | null createdAt: string - updatedAt: string } + updatedAt: string + uploadedUser: { id: number; name: string } | null } export type PostTagChange = { post: Post -- 2.34.1 From cac4ad7f51e5fd5d98f421b492199ee0e82efe2b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 15:25:32 +0900 Subject: [PATCH 08/13] #61 --- backend/app/representations/post_repr.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/representations/post_repr.rb b/backend/app/representations/post_repr.rb index 2dc444f..bcba375 100644 --- a/backend/app/representations/post_repr.rb +++ b/backend/app/representations/post_repr.rb @@ -8,7 +8,7 @@ module PostRepr def base post, current_user = nil json = post.as_json(BASE) - return json unless current_user + return json.merge(viewed: false) unless current_user viewed = current_user.viewed?(post) json.merge(viewed:) -- 2.34.1 From a7afe5f4d5a588153a2603bc14612f46ead9874d Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 16:07:32 +0900 Subject: [PATCH 09/13] #61 --- frontend/src/pages/posts/PostDetailPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx index 4c11c62..16697ba 100644 --- a/frontend/src/pages/posts/PostDetailPage.tsx +++ b/frontend/src/pages/posts/PostDetailPage.tsx @@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/use-toast' import { SITE_TITLE } from '@/config' import { fetchPost, toggleViewedFlg } from '@/lib/posts' -import { postsKeys } from '@/lib/queryKeys' +import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import NotFound from '@/pages/NotFound' import ServiceUnavailable from '@/pages/ServiceUnavailable' @@ -140,6 +140,7 @@ export default (({ user }: Props) => { qc.setQueryData (postsKeys.show (postId), (prev: any) => newPost ?? prev) qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) toast ({ description: '更新しました.' }) }}/> )} -- 2.34.1 From e399707fbfae3eafacddc0d681d71befccda19aa Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 19:53:11 +0900 Subject: [PATCH 10/13] #61 --- frontend/src/lib/posts.ts | 7 +++++-- frontend/src/lib/prefetchers.ts | 18 +++++++++++++++--- frontend/src/lib/queryKeys.ts | 2 +- frontend/src/lib/tags.ts | 12 ++++++++++++ frontend/src/pages/posts/PostHistoryPage.tsx | 19 ++++++++++++++++--- frontend/src/pages/tags/TagListPage.tsx | 8 ++++++++ 6 files changed, 57 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/posts.ts b/frontend/src/lib/posts.ts index 3570abb..7ee14c0 100644 --- a/frontend/src/lib/posts.ts +++ b/frontend/src/lib/posts.ts @@ -29,14 +29,17 @@ export const fetchPost = async (id: string): Promise => await apiGet (`/po export const fetchPostChanges = async ( - { id, page, limit }: { + { id, tag, page, limit }: { id?: string + tag?: string page: number limit: number }, ): Promise<{ changes: PostTagChange[] count: number }> => - await apiGet ('/posts/changes', { params: { ...(id && { id }), page, limit } }) + await apiGet ('/posts/changes', { params: { ...(id && { id }), + ...(tag && { tag }), + page, limit } }) export const toggleViewedFlg = async (id: string, viewed: boolean): Promise => { diff --git a/frontend/src/lib/prefetchers.ts b/frontend/src/lib/prefetchers.ts index 0d4f2d8..ad18e1b 100644 --- a/frontend/src/lib/prefetchers.ts +++ b/frontend/src/lib/prefetchers.ts @@ -3,7 +3,7 @@ import { match } from 'path-to-regexp' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' -import { fetchTagByName, fetchTags } from '@/lib/tags' +import { fetchTagByName, fetchTag, fetchTags } from '@/lib/tags' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' @@ -122,12 +122,24 @@ const prefetchPostShow: Prefetcher = async (qc, url) => { const prefetchPostChanges: Prefetcher = async (qc, url) => { const id = url.searchParams.get ('id') + const tag = url.searchParams.get ('tag') const page = Number (url.searchParams.get ('page') || 1) const limit = Number (url.searchParams.get ('limit') || 20) + if (tag) + { + await qc.prefetchQuery ({ + queryKey: tagsKeys.show (tag), + queryFn: () => fetchTag (tag) }) + } + await qc.prefetchQuery ({ - queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }), - queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) }) + queryKey: postsKeys.changes ({ ...(id && { id }), + ...(tag && { tag }), + page, limit }), + queryFn: () => fetchPostChanges ({ ...(id && { id }), + ...(tag && { tag }), + page, limit }) }) } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index b0d42f2..65a8be5 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -5,7 +5,7 @@ export const postsKeys = { index: (p: FetchPostsParams) => ['posts', 'index', p] as const, show: (id: string) => ['posts', id] as const, related: (id: string) => ['related', id] as const, - changes: (p: { id?: string; page: number; limit: number }) => + changes: (p: { id?: string; tag?: string; page: number; limit: number }) => ['posts', 'changes', p] as const } export const tagsKeys = { diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 2935934..8a7829f 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -23,6 +23,18 @@ export const fetchTags = async ( ...(order && { order }) } }) +export const fetchTag = async (id: string): Promise => { + try + { + return await apiGet (`/tags/${ id }`) + } + catch + { + return null + } +} + + export const fetchTagByName = async (name: string): Promise => { try { diff --git a/frontend/src/pages/posts/PostHistoryPage.tsx b/frontend/src/pages/posts/PostHistoryPage.tsx index a9a5ea9..4652441 100644 --- a/frontend/src/pages/posts/PostHistoryPage.tsx +++ b/frontend/src/pages/posts/PostHistoryPage.tsx @@ -11,7 +11,8 @@ import Pagination from '@/components/common/Pagination' import MainArea from '@/components/layout/MainArea' import { SITE_TITLE } from '@/config' import { fetchPostChanges } from '@/lib/posts' -import { postsKeys } from '@/lib/queryKeys' +import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { fetchTag } from '@/lib/tags' import { cn, dateString } from '@/lib/utils' import type { FC } from 'react' @@ -21,15 +22,26 @@ export default (() => { const location = useLocation () const query = new URLSearchParams (location.search) const id = query.get ('id') + const tagId = query.get ('tag') const page = Number (query.get ('page') ?? 1) const limit = Number (query.get ('limit') ?? 20) // 投稿列の結合で使用 let rowsCnt: number + const { data: tag } = + tagId + ? useQuery ({ queryKey: tagsKeys.show (tagId), + queryFn: () => fetchTag (tagId) }) + : { data: null } + const { data, isLoading: loading } = useQuery ({ - queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }), - queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) }) + queryKey: postsKeys.changes ({ ...(id && { id }), + ...(tagId && { tag: tagId }), + page, limit }), + queryFn: () => fetchPostChanges ({ ...(id && { id }), + ...(tagId && { tag: tagId }), + page, limit }) }) const changes = data?.changes ?? [] const totalPages = data ? Math.ceil (data.count / limit) : 0 @@ -48,6 +60,7 @@ export default (() => { 耕作履歴 {id && <>: 投稿 {#{id}}} + {tag && <>()} {loading ? 'Loading...' : ( diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index fd9dc60..e1ce2fc 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useLocation, useNavigate } from 'react-router-dom' +import PrefetchLink from '@/components/PrefetchLink' import SortHeader from '@/components/SortHeader' import TagLink from '@/components/TagLink' import DateTimeField from '@/components/common/DateTimeField' @@ -211,6 +212,7 @@ export default (() => { + @@ -250,6 +252,7 @@ export default (() => { currentOrder={order} defaultDirection={defaultDirection}/> + @@ -263,6 +266,11 @@ export default (() => { {row.postCount} {dateString (row.createdAt)} {dateString (row.updatedAt)} + + + 耕作履歴 + + ))} -- 2.34.1 From 0eab847ebb88c9bf422deed5aabe3a4f769dad66 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 15 Mar 2026 19:54:37 +0900 Subject: [PATCH 11/13] #61 --- backend/app/controllers/posts_controller.rb | 2 + backend/spec/requests/posts_spec.rb | 87 +++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 2bf91cf..74d720c 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -185,6 +185,7 @@ class PostsController < ApplicationController def changes id = params[:id].presence + tag_id = params[:tag].presence page = (params[:page].presence || 1).to_i limit = (params[:limit].presence || 20).to_i @@ -195,6 +196,7 @@ class PostsController < ApplicationController pts = PostTag.with_discarded pts = pts.where(post_id: id) if id.present? + pts = pts.where(tag_id:) if tag_id.present? pts = pts.includes(:post, :created_user, :deleted_user, tag: { tag_name: :wiki_page }) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index ee4fcc5..120a221 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -667,6 +667,93 @@ RSpec.describe 'Posts API', type: :request do expect(types).to include('add') expect(types).to include('remove') end + + it 'filters history by tag' do + tn2 = TagName.create!(name: 'history_tag_hit') + tag2 = Tag.create!(tag_name: tn2, category: :general) + + tn3 = TagName.create!(name: 'history_tag_miss') + tag3 = Tag.create!(tag_name: tn3, category: :general) + + other_post = Post.create!( + title: 'other post', + url: 'https://example.com/history-other' + ) + + # hit: add + PostTag.create!(post: post_record, tag: tag2, created_user: member) + + # hit: add + remove + pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member) + pt2.discard_by!(member) + + # miss: add + remove + pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member) + pt3.discard_by!(member) + + get '/posts/changes', params: { tag: tag2.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('changes', 'count') + expect(json['count']).to eq(3) + + changes = json.fetch('changes') + + expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id]) + expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove]) + expect(changes.map { |e| e.dig('post', 'id') }).to match_array([ + post_record.id, + other_post.id, + other_post.id + ]) + end + + it 'filters history by post and tag together' do + tn2 = TagName.create!(name: 'history_tag_combo_hit') + tag2 = Tag.create!(tag_name: tn2, category: :general) + + tn3 = TagName.create!(name: 'history_tag_combo_miss') + tag3 = Tag.create!(tag_name: tn3, category: :general) + + other_post = Post.create!( + title: 'other combo post', + url: 'https://example.com/history-combo-other' + ) + + # hit + PostTag.create!(post: post_record, tag: tag2, created_user: member) + + # miss by post + pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member) + pt2.discard_by!(member) + + # miss by tag + pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member) + pt3.discard_by!(member) + + get '/posts/changes', params: { id: post_record.id, tag: tag2.id } + + expect(response).to have_http_status(:ok) + expect(json).to include('changes', 'count') + expect(json['count']).to eq(1) + + changes = json.fetch('changes') + expect(changes.size).to eq(1) + expect(changes[0]['change_type']).to eq('add') + expect(changes[0].dig('post', 'id')).to eq(post_record.id) + expect(changes[0].dig('tag', 'id')).to eq(tag2.id) + end + + it 'returns empty history when tag does not match' do + tn2 = TagName.create!(name: 'history_tag_no_hit') + tag2 = Tag.create!(tag_name: tn2, category: :general) + + get '/posts/changes', params: { tag: tag2.id } + + expect(response).to have_http_status(:ok) + expect(json.fetch('changes')).to eq([]) + expect(json.fetch('count')).to eq(0) + end end describe 'POST /posts/:id/viewed' do -- 2.34.1 From 930d020f2aa71046d9386de33089af519f94f031 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 15:41:05 +0900 Subject: [PATCH 12/13] #61 --- frontend/src/components/TagDetailSidebar.tsx | 6 ++++++ frontend/src/types.ts | 14 +++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 8579442..c02eca9 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -6,6 +6,7 @@ import { DndContext, useSensor, useSensors } from '@dnd-kit/core' import { restrictToWindowEdges } from '@dnd-kit/modifiers' +import { useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { useEffect, useMemo, useRef, useState } from 'react' @@ -19,6 +20,7 @@ import SidebarComponent from '@/components/layout/SidebarComponent' import { toast } from '@/components/ui/use-toast' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' +import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { dateString, originalCreatedAtString } from '@/lib/utils' import type { DragEndEvent } from '@dnd-kit/core' @@ -152,6 +154,8 @@ type Props = { post: Post; sp?: boolean } export default (({ post, sp }: Props) => { sp = Boolean (sp) + const qc = useQueryClient () + const baseTags = useMemo (() => { const tagsTmp = { } as TagByCategory @@ -181,6 +185,8 @@ export default (({ post, sp }: Props) => { const reloadTags = async (): Promise => { setTags (buildTagByCategory (await apiGet (`/posts/${ post.id }`))) + qc.invalidateQueries ({ queryKey: postsKeys.root }) + qc.invalidateQueries ({ queryKey: tagsKeys.root }) } const onDragEnd = async (e: DragEndEvent) => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index caaac6e..36f8b32 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -133,13 +133,13 @@ export type Tag = { matchedAlias?: string | null } export type Theatre = { - id: number - name: string | null - opensAt: string - closesAt: string | null - createdByUser: { id: number; name: string } - createdAt: string - updatedAt: string } + id: number + name: string | null + opensAt: string + closesAt: string | null + createdByUser: { id: number; name: string } + createdAt: string + updatedAt: string } export type User = { id: number -- 2.34.1 From 6dedf4148d7e6cedbca2e000070eb7cc39bb79a7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 19:53:54 +0900 Subject: [PATCH 13/13] #61 --- frontend/src/components/TagSidebar.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/TagSidebar.tsx b/frontend/src/components/TagSidebar.tsx index a91966e..d0bf5cc 100644 --- a/frontend/src/components/TagSidebar.tsx +++ b/frontend/src/components/TagSidebar.tsx @@ -104,14 +104,10 @@ export default (({ posts, onClick }: Props) => { {tagsVsbl && ( {posts.length > 0 && TagBlock} )} -- 2.34.1