素材管理 (#306) (#381)

開発環境では、**DB を壊さない前提で、migration → API → 画面 → 同期 → ZIP → 履歴**の順に見るのがよいです。今回の差分は素材管理全体に触っているので、単体でチョンチョン見るより、素材の一生を通すのが早いです。

## 0. 先に方針

**やらないこと:**

```sh id="snb3i6"
rails db:drop
rails db:reset
rails db:setup
DISABLE_DATABASE_ENVIRONMENT_CHECK=1 ...
```

これは禁止。
開発 DB に本番データを入れているなら、床板を剥がして耐震確認するようなものです。

---

## 1. migration 確認

まず現在の状態を見る。

```sh id="qsdfpt"
cd backend
RAILS_ENV=development bundle exec rails db:migrate:status
```

その後、通常 migration。

```sh id="a89fir"
RAILS_ENV=development bundle exec rails db:migrate
```

見るポイント:

```txt id="c1u57a"
materials に source_* / normalized_source_key / version_no がある
material_versions に event_type / file snapshot / source snapshot がある
material_export_items がある
material_sync_suppressions がある
material_sync_sources がある
既存 materials に material_versions version_no=1 create が backfill されている
```

確認用:

```sh id="g4vd4m"
RAILS_ENV=development bundle exec rails runner '
puts "materials=#{Material.count}"
puts "versions=#{MaterialVersion.count}"
puts "materials without versions=#{Material.left_joins(:material_versions).where(material_versions: { id: nil }).count}"
puts "sync suppressions table=#{ActiveRecord::Base.connection.table_exists?(:material_sync_suppressions)}"
'
```

ここで `materials without versions=0` になれば、backfill は通っています。

---

## 2. 既存素材一覧の画面確認

フロントを起動して `/materials` を見る。

```sh id="vl28jd"
cd frontend
npm run dev
```

見る観点:

```txt id="i2ovxw"
初期表示で素材が出る
初期表示ではグルーピングがオフ
タグなし素材も出る
カード表示でサムネまたは代替テキストが出る
一覧表示に切り替えられる
q / tag_state / media_kind / sort / direction が効く
```

ここでまず、普通の素材一覧が壊れていないことを確認します。

---

## 3. 左タグバーの確認

`/materials` を開いて左タグバーからタグを選ぶ。

見る観点:

```txt id="n6udul"
URL が tag_id=...&include_descendants=1&group_by=parent_tag になる
選択中タグが左バーで強調される
一覧上部に「選択中」の表示が出る
「タグ選択を解除」で通常表示に戻れる
解除後、tag_id / include_descendants / group_by / page が消える
子タグ・孫タグの素材も一覧に出る
親タググルーピングされる
```

特に重要なのはこれ。

```txt id="d33os0"
親タグ A を選択
  A に直接紐づく素材
  A > B に紐づく素材
  A > B > C に紐づく素材
が同じ一覧に出ること
```

---

## 4. 選択タグから素材追加

左タグからタグを選択した状態で、一覧上部の **このタグに素材を追加** を押す。

見る観点:

```txt id="qdd5uw"
素材追加画面の tag 欄に選択中タグ名が初期入力されている
file または url を指定して保存できる
保存後 return_to で元のタグ選択済み一覧に戻る
戻った一覧に追加した素材が出る
material_versions に create が 1 件できる
```

Rails console でも確認できます。

```sh id="i07rrb"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(id: :desc).first
puts({ id: m.id, tag: m.tag&.name, versions: m.material_versions.count, version_no: m.version_no }.inspect)
'
```

---

## 5. グループ見出しから素材追加

親タググルーピング表示中に、各グループ見出しの **このタグに素材を追加** を押す。

見る観点:

```txt id="fpx8w4"
グループタグ名が tag 欄に初期入力される
保存後、元の一覧に戻る
追加素材がそのグループ内に出る
```

ここは今回の導線の肝です。棚の見出しから直接その棚へ素材を置けるかを見る。

---

## 6. 素材更新と履歴

既存素材の詳細または編集導線から、タグ・URL・ファイル・export path を更新する。

見る観点:

```txt id="w36i61"
更新前 snapshot が無ければ create が補われる
更新後 update version ができる
file_blob_id / file_filename / file_sha256 が material_versions に入る
export_paths_json が履歴に残る
/materials/changes または /materials/versions で履歴が見える
```

console:

```sh id="a3okhv"
RAILS_ENV=development bundle exec rails runner '
m = Material.order(updated_at: :desc).first
puts m.material_versions.order(:version_no).map { |v|
  [v.version_no, v.event_type, v.tag_name, v.file_filename, v.file_sha256, v.export_paths_hash]
}.inspect
'
```

---

## 7. サムネイル

画像素材を追加して、一覧にサムネイルが出るか確認。

動画素材があるなら、`ffmpeg` が入っている環境で backfill。

```sh id="qxm8fj"
cd backend
RAILS_ENV=development bundle exec rails materials:thumbnails:backfill
```

見る観点:

```txt id="s9a1vf"
画像は 180x180 のサムネが付く
動画はフレームからサムネが作られる
非対応ファイルは代替テキスト表示になる
ログに result が出る
```

---

## 8. ZIP export

export path がある素材を用意して、ブラウザで確認。

```txt id="aq7f7o"
/materials/download.zip?profile=legacy_drive
```

見る観点:

```txt id="h4rve5"
ZIP が落ちる
entry path が material_export_items.export_path になる
disabled な export item は入らない
ファイル実体が欠けている場合は 422 と missing_files が返る
```

---

## 9. 抑止

`/materials/suppressions` で path prefix 抑止を追加する。

見る観点:

```txt id="w80jbu"
member で作成できる
guest は forbidden / unauthorized
google_drive_path_prefix で既存素材が discard される
discard 履歴が material_versions に残る
同期時に同じ source_path 配下が再作成されない
```

---

## 10. Google Drive 同期

開発環境では、まず小さいフォルダでやるのがよいです。
いきなり本番素材集フォルダを食わせると、ログが藪になります 🌿

必要な ENV:

```sh id="kzbb7f"
GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL=...
GOOGLE_DRIVE_PRIVATE_KEY_PATH=...
MATERIAL_SYNC_SOURCE_KIND=google_drive_path
MATERIAL_SYNC_SOURCE_FILE_ID=<folder_id>
MATERIAL_SYNC_SOURCE_NAME=dev-small-folder
MATERIAL_SYNC_SOURCE_PROFILE=legacy_drive
```

seed で source 作成、または console で作成。

```sh id="sec40c"
RAILS_ENV=development bundle exec rails db:seed
RAILS_ENV=development bundle exec rails materials:sync
```

見る観点:

```txt id="a5ltqu"
imported / updated / unchanged / suppressed / failed がログに出る
2 回目実行で unchanged が増える
tag は nil のままでも保存できる
人手で tag / url を付けた既存同期素材が、再同期で消えない
Google native file は skip される
download 後 sha256 block が効く
```

---

## 11. schema.rb は別途確認

これはテストというより merge gate です。
今回まだ怪しいので、差分に以下が混ざっていないことを確認します。

```txt id="s3k6da"
wiki_assets 削除
wiki_pages.next_asset_no 削除
素材管理と無関係な CHECK constraint 削除
素材管理と無関係な index order 消失
```

ここが残るなら、機能テストが通っても merge は止めた方がいいです。

---

## 最小テスト順

時間がないなら、この順で十分です。

```txt id="c1k2pw"
1. db:migrate
2. materials without versions = 0 を確認
3. /materials 初期表示
4. 左タグ選択 → 子孫込み表示 → グルーピング
5. タグ選択解除
6. 選択タグから素材追加 → return_to で戻る
7. グループ見出しから素材追加
8. 更新して material_versions を確認
9. ZIP export
10. 小さい Drive folder で materials:sync を 2 回
```

これで、今回の差分の主要な導線はほぼ踏めます。

Reviewed-on: #381
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #381 でマージされました.
このコミットが含まれているのは:
2026-06-28 06:35:53 +09:00
committed by みてるぞ
コミット 776dea87d9
88個のファイルの変更6330行の追加1143行の削除
+6 -12
ファイルの表示
@@ -23,8 +23,7 @@ const apiP = async <T> (
method: 'post' | 'put' | 'patch',
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
@@ -34,8 +33,7 @@ const apiP = async <T> (
export const apiGet = async <T> (
path: string,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client.get (path, withUserCode (opt))
if (opt?.responseType === 'blob')
return res.data as T
@@ -46,28 +44,24 @@ export const apiGet = async <T> (
export const apiPost = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('post', path, body, opt)
opt?: Opt): Promise<T> => apiP ('post', path, body, opt)
export const apiPut = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('put', path, body, opt)
opt?: Opt): Promise<T> => apiP ('put', path, body, opt)
export const apiPatch = async <T> (
path: string,
body?: unknown,
opt?: Opt,
): Promise<T> => apiP ('patch', path, body, opt)
opt?: Opt): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async <T = void> (
path: string,
opt?: Opt,
): Promise<T> => {
opt?: Opt): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
+14 -28
ファイルの表示
@@ -104,8 +104,7 @@ export type BuildGekanatorQuestionsOptions = {
export const normalizeTitleLengthCondition = (
condition: GekanatorQuestionCondition,
): GekanatorQuestionCondition => {
condition: GekanatorQuestionCondition): GekanatorQuestionCondition => {
switch (condition.type)
{
case 'title-length-greater-than':
@@ -119,8 +118,7 @@ export const normalizeTitleLengthCondition = (
export const titleLengthMinimumForCondition = (
condition: GekanatorQuestionCondition,
): number | null => {
condition: GekanatorQuestionCondition): number | null => {
switch (condition.type)
{
case 'title-length-at-least':
@@ -134,8 +132,7 @@ export const titleLengthMinimumForCondition = (
export const questionIdForCondition = (
condition: NonPostSimilarityCondition,
): string => {
condition: NonPostSimilarityCondition): string => {
switch (condition.type)
{
case 'tag':
@@ -161,8 +158,7 @@ export const questionIdForCondition = (
const directExampleAnswerFor = (
question: StoredGekanatorQuestion,
post: Post,
): GekanatorAnswerValue | null => {
post: Post): GekanatorAnswerValue | null => {
if (question.kind !== 'post_similarity' && question.kind !== 'tag')
return null
@@ -178,15 +174,13 @@ const directExampleAnswerFor = (
export const isLearnedSemanticQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion,
): boolean =>
question: StoredGekanatorQuestion | GekanatorQuestion): boolean =>
question.kind === 'post_similarity'
&& question.source === 'user_suggested'
export const learnedSemanticSideForAnswer = (
answer: GekanatorAnswerValue | null,
): LearnedSemanticSide => {
answer: GekanatorAnswerValue | null): LearnedSemanticSide => {
if (answer === 'yes' || answer === 'partial')
return 'positive'
@@ -314,8 +308,7 @@ const questionableTag = (post: Post, key: string): boolean => {
const questionMatches = (
post: Post,
question: StoredGekanatorQuestion,
): boolean => {
question: StoredGekanatorQuestion): boolean => {
const directAnswer = directExampleAnswerFor (question, post)
if (directAnswer)
return question.kind === 'post_similarity'
@@ -350,8 +343,7 @@ const questionMatches = (
export const expectedAnswerForQuestion = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): GekanatorAnswerValue | null => {
post: Post | null): GekanatorAnswerValue | null => {
if (!(question) || !(post))
return null
@@ -382,14 +374,12 @@ export const expectedAnswerForQuestion = (
export const learnedSemanticSideForPost = (
question: StoredGekanatorQuestion | GekanatorQuestion | undefined,
post: Post | null,
): LearnedSemanticSide =>
post: Post | null): LearnedSemanticSide =>
learnedSemanticSideForAnswer (expectedAnswerForQuestion (question, post))
export const restoreGekanatorQuestion = (
question: StoredGekanatorQuestion,
): GekanatorQuestion => {
question: StoredGekanatorQuestion): GekanatorQuestion => {
const normalizedCondition = normalizeTitleLengthCondition (question.condition)
const normalizedQuestion = {
...question,
@@ -408,8 +398,7 @@ export const restoreGekanatorQuestion = (
export const storeGekanatorQuestion = (
question: GekanatorQuestion,
): StoredGekanatorQuestion => ({
question: GekanatorQuestion): StoredGekanatorQuestion => ({
id: question.condition.type === 'title-length-greater-than'
? `title:length-at-least:${ question.condition.length + 1 }`
: question.id,
@@ -436,8 +425,7 @@ export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string,
): Promise<GekanatorExtraQuestion[]> => {
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
@@ -447,8 +435,7 @@ export const fetchGekanatorExtraQuestions = async (
export const buildGekanatorQuestions = (
posts: Post[],
options: BuildGekanatorQuestionsOptions = { },
): GekanatorQuestion[] => {
options: BuildGekanatorQuestionsOptions = { }): GekanatorQuestion[] => {
const {
includeTitleContains = true,
tagQuestionCap = 192,
@@ -490,8 +477,7 @@ export const buildGekanatorQuestions = (
const usefulEntries = <T extends string | number> (
counts: Map<T, number>,
cap: number,
) =>
cap: number) =>
[...counts.entries ()]
.filter (([, count]) => count > 0 && count < posts.length)
.sort ((a, b) => Math.abs (posts.length / 2 - a[1])
+4 -8
ファイルの表示
@@ -32,8 +32,7 @@ export const candidatePostsFor = (
answers: GekanatorAnswerLog[]
softenedQuestionIds: Set<string>
rejectedPostIds: Set<number>
recoveredCandidatePosts: Map<number, RecoveredCandidateState> },
): Post[] => {
recoveredCandidatePosts: Map<number, RecoveredCandidateState> }): Post[] => {
const questionById = new Map (questions.map (question => [question.id, question]))
return posts.filter (post => {
@@ -76,8 +75,7 @@ export const candidatePostsFor = (
export const hardFilteredPostsForAnswer = (
{ posts, question, answer }: { posts: Post[]
question: GekanatorQuestion
answer: GekanatorAnswerValue },
): Post[] => {
answer: GekanatorAnswerValue }): Post[] => {
if (!(questionSupportsAnswerBasedHardFiltering (question)))
return posts
@@ -98,8 +96,7 @@ const concreteAnswerOptions: GekanatorAnswerValue[] = ['yes', 'no', 'partial', '
export const allConcreteAnswerOptionsExhausted = (
posts: Post[],
question: GekanatorQuestion | null,
): boolean => {
question: GekanatorQuestion | null): boolean => {
if (!(question))
return false
@@ -125,8 +122,7 @@ export const recoverCandidatePosts = (
recoveredCandidatePosts: Map<number, RecoveredCandidateState>
eligiblePostIds: Set<number>
answerCountAtRecovery: number
recoveryStepCount: number },
): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number }): { recoveredCandidatePosts: Map<number, RecoveredCandidateState>
recoveryStepCount: number } | null => {
const recovered = new Map (recoveredCandidatePosts)
const targetSize = nextRecoveryTargetSize (recoveryStepCount)
+8 -16
ファイルの表示
@@ -8,8 +8,7 @@ import type {
export const monthForCondition = (
condition: GekanatorQuestion['condition'],
): number | null => {
condition: GekanatorQuestion['condition']): number | null => {
if (condition.type === 'original-month')
return condition.month
@@ -24,8 +23,7 @@ export const monthForCondition = (
const isTitleLengthContradiction = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
const candidateLength = titleLengthMinimumForCondition (candidate)
const previousLength = titleLengthMinimumForCondition (previous)
if (candidateLength === null || previousLength === null)
@@ -45,8 +43,7 @@ const isTitleLengthContradiction = (
const isQuestionRedundantAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
const previous = answer.questionCondition
return previous !== undefined
&& isTitleLengthContradiction (question.condition, previous, answer.answer)
@@ -56,8 +53,7 @@ const isQuestionRedundantAfterAnswers = (
const isSourceFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (candidate.type !== 'source' || previous.type !== 'source')
return false
@@ -76,8 +72,7 @@ const isSourceFactBlocked = (
const isOriginalYearFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (candidate.type !== 'original-year' || previous.type !== 'original-year')
return false
@@ -96,8 +91,7 @@ const isOriginalYearFactBlocked = (
const isOriginalMonthFactBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
switch (answer)
{
case 'yes':
@@ -143,8 +137,7 @@ const isOriginalMonthFactBlocked = (
const isFactQuestionBlocked = (
candidate: GekanatorQuestion['condition'],
previous: GekanatorQuestion['condition'],
answer: GekanatorAnswerValue,
): boolean => {
answer: GekanatorAnswerValue): boolean => {
if (!(answer === 'yes' || answer === 'no'))
return false
@@ -156,8 +149,7 @@ const isFactQuestionBlocked = (
export const isQuestionHardFilteredAfterAnswers = (
question: GekanatorQuestion,
answers: GekanatorAnswerLog[],
): boolean => answers.some (answer => {
answers: GekanatorAnswerLog[]): boolean => answers.some (answer => {
const previous = answer.questionCondition
if (previous === undefined)
return false
+139
ファイルの表示
@@ -0,0 +1,139 @@
import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
import type { Material,
MaterialIndexResponse,
MaterialVersion,
FetchMaterialsParams,
MaterialFilter,
MaterialSyncSuppression,
MaterialSidebarTag,
MaterialTagTree } from '@/types'
export type FetchMaterialTreeParams = {
parentId?: number | null
materialFilter: MaterialFilter }
export type MaterialSyncSuppressionResponse = { suppressions: MaterialSyncSuppression[] }
export type MaterialChangesResponse = {
versions: MaterialVersion[]
count: number }
const MATERIAL_FILTERS: MaterialFilter[] = ['present', 'missing', 'any']
export const parseMaterialFilter = (
value: unknown,
fallback: MaterialFilter = 'present',
): MaterialFilter =>
typeof value === 'string' && MATERIAL_FILTERS.includes (value as MaterialFilter)
? value as MaterialFilter
: fallback
export const fetchMaterials = async (
{ q, tagState, mediaKind, createdFrom, createdTo,
updatedFrom, updatedTo, sort, direction, page,
tagId, includeDescendants, groupBy,
limit }: FetchMaterialsParams,
): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: {
...(q && { q }),
tag_state: tagState,
media_kind: mediaKind,
...(tagId != null && { tag_id: tagId }),
...(includeDescendants && { include_descendants: '1' }),
...(groupBy !== 'none' && { group_by: groupBy }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
...(updatedFrom && { updated_from: updatedFrom }),
...(updatedTo && { updated_to: updatedTo }),
sort,
direction,
page,
limit} })
export const fetchMaterial = async (id: string): Promise<Material | null> => {
try
{
return await apiGet (`/materials/${ id }`)
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return null
throw error
}
}
export const fetchMaterialChanges = async (
{ materialId, tag, eventType, page, limit }: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { params: {
...(materialId && { material_id: materialId }),
...(tag && { tag }),
...(eventType && { event_type: eventType }),
page,
limit} })
export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: {
...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter} })
export const fetchMaterialTagByName = async (
name: string,
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
try
{
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
{ params: { material_filter: materialFilter } })
}
catch (error)
{
if (isApiError (error) && error.response?.status === 404)
return null
throw error
}
}
export const createMaterial = async (formData: FormData): Promise<Material> =>
await apiPost ('/materials', formData)
export const updateMaterial = async (
id: string,
formData: FormData): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData)
export const fetchMaterialSyncSuppressions =
async (): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions')
export const createMaterialSyncSuppression = async (
payload: {
sourceKind: string
sourceUri?: string
drivePath?: string
driveFileId?: string
reason: string
}): Promise<MaterialSyncSuppression> =>
await apiPost ('/materials/suppressions', {
source_kind: payload.sourceKind,
source_uri: payload.sourceUri,
drive_path: payload.drivePath,
drive_file_id: payload.driveFileId,
reason: payload.reason})
+3 -6
ファイルの表示
@@ -5,8 +5,7 @@ import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
): Promise<{
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: {
@@ -33,8 +32,7 @@ export const fetchPostChanges = async (
post?: string
tag?: string
page: number
limit: number },
): Promise<{
limit: number }): Promise<{
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }),
@@ -52,8 +50,7 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }
) =>
merge?: boolean }) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ title: post.title,
+28 -1
ファイルの表示
@@ -1,4 +1,10 @@
import type { FetchNicoTagsParams, FetchPostsParams, FetchTagsParams } from '@/types'
import type {
FetchNicoTagsParams,
FetchMaterialsParams,
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
export const postsKeys = {
root: ['posts'] as const,
@@ -25,6 +31,27 @@ export const tagsKeys = {
['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
export const materialsKeys = {
root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
changes: (p: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}) => ['materials', 'changes', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const,
suppressions: () => ['materials', 'suppressions'] as const,
tree: (p: {
parentId?: number | null
materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const}
export const wikiKeys = {
root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
+4 -8
ファイルの表示
@@ -11,8 +11,7 @@ import type { Deerjikist,
export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams,
): Promise<{ tags: Tag[]
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
count: number }> =>
await apiGet ('/tags', { params: {
...(post != null && { post }),
@@ -31,8 +30,7 @@ export const fetchTags = async (
export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
): Promise<{ tags: NicoTag[]
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[]
count: number }> =>
await apiGet ('/tags/nico', { params: {
page,
@@ -70,14 +68,12 @@ export const fetchTagChanges = async (
{ id, page, limit }: {
id?: string
page: number
limit: number },
): Promise<{
limit: number }): Promise<{
versions: TagVersion[]
count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async (
id: string,
): Promise<{ tag: Tag; deerjikists: Deerjikist[]}> =>
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
await apiGet (`/tags/${ id }/deerjikists`)
+1 -2
ファイルの表示
@@ -4,5 +4,4 @@ const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = (
user: Pick<User, 'role'> | null | undefined,
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
user: Pick<User, 'role'> | null | undefined): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
+2 -4
ファイルの表示
@@ -12,8 +12,7 @@ export const cn = (...inputs: ClassValue[]) => twMerge (clsx (...inputs))
export const dateString = (
d: string | Date,
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null,
): string =>
unknown: 'month' | 'day' | 'hour' | 'minute' | 'second' | null = null): string =>
toDate (d).toLocaleString (
'ja-JP-u-ca-japanese',
{ era: 'long',
@@ -28,8 +27,7 @@ export const dateString = (
export const originalCreatedAtString = (
f: string | Date | null,
b: string | Date | null,
): string => {
b: string | Date | null): string => {
const from = f ? toDate (f) : null
const before = b ? toDate (b) : null
+3 -6
ファイルの表示
@@ -4,22 +4,19 @@ import type { WikiPage } from '@/types'
export const fetchWikiPages = async (
{ title }: { title?: string },
): Promise<WikiPage[]> =>
{ title }: { title?: string }): Promise<WikiPage[]> =>
await apiGet ('/wiki', { params: { title } })
export const fetchWikiPage = async (
id: string,
{ version }: { version?: string },
): Promise<WikiPage> =>
{ version }: { version?: string }): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
export const fetchWikiPageByTitle = async (
title: string,
{ version }: { version?: string },
): Promise<WikiPage | null> => {
{ version }: { version?: string }): Promise<WikiPage | null> => {
try
{
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })