このコミットが含まれているのは:
2026-10-18 02:45:26 +09:00
コミット 2d1a8b4730
17個のファイルの変更、488行の追加、326行の削除
+12 -6
ファイルの表示
@@ -8,13 +8,13 @@ import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials' import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types' import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any'] const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const px = (value: string): number => { const px = (value: string): number => {
@@ -96,9 +96,11 @@ const updateMaterialFilterQuery = (
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void }> = ( onChange: (materialFilter: MaterialFilter) => void }> = (
{ materialFilter, onChange }, { materialFilter, onChange },
) => ( ) => {
const { label } = useUITranslation ('materials')
return (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center"> <div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold">素材:</label> <label className="my-auto text-sm font-bold">{label ('sidebar.label')}</label>
{FILTERS.map (value => ( {FILTERS.map (value => (
<button <button
key={value} key={value}
@@ -111,9 +113,10 @@ const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
'dark:bg-sky-950 dark:text-sky-100'] 'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700', : ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}> 'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]} {label (`sidebar.${value}`)}
</button>))} </button>))}
</div>) </div>)
}
const MaterialTreeNode: FC<{ const MaterialTreeNode: FC<{
@@ -385,6 +388,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const MaterialSidebar: FC = () => { const MaterialSidebar: FC = () => {
const { label } = useUITranslation ('materials')
const locale = useLocale () const locale = useLocale ()
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
@@ -498,10 +502,12 @@ const MaterialSidebar: FC = () => {
materialFilter={materialFilter} materialFilter={materialFilter}
onChange={handleFilterChange}/> onChange={handleFilterChange}/>
{isLoading && ( {isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)} <p className="text-sm text-neutral-500 dark:text-stone-400">
{label ('shared:messages.loadingEllipsis')}
</p>)}
{isError && ( {isError && (
<p className="text-sm text-red-600 dark:text-red-300"> <p className="text-sm text-red-600 dark:text-red-300">
タグ一覧の取得に失敗しました. {label ('sidebar.error')}
</p>)} </p>)}
{(!isLoading && !isError) && ( {(!isLoading && !isError) && (
<ul> <ul>
+17 -11
ファイルの表示
@@ -13,6 +13,7 @@ import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils' import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -53,6 +54,7 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => { const PostEditForm: FC<Props> = ({ post, onSave }) => {
const locale = useLocale () const locale = useLocale ()
const { label } = useUITranslation ('posts')
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs)) const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
@@ -88,7 +90,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
siblingPosts: data.siblingPosts, siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom, originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post) originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' }) toast ({ description: label ('shared:messages.updated') })
} }
catch (e) catch (e)
{ {
@@ -98,24 +100,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{ {
if (applyValidationError (e)) if (applyValidationError (e))
{ {
toast ({ description: '更新はできなかったよ……' }) toast ({ description: label ('edit.validationFailed') })
return return
} }
toast ({ title: '失敗……', description: '入力を確認してください.' }) toast ({ title: label ('shared:messages.failed'),
description: label ('shared:messages.checkInput') })
return return
} }
const action = await dialogue.choice ({ const action = await dialogue.choice ({
title: '競合が発生しました.', title: label ('edit.conflictTitle'),
description: ( description: (
<div> <div>
<p>ほかの耕作員が先に更新してゐます.</p> <p>{label ('edit.conflictDescription')}</p>
<p>現在の変更をどう扱ひますか?</p> <p>{label ('edit.conflictQuestion')}</p>
</div>), </div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []), cancelText: label ('shared:actions.cancel'),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) closeLabel: label ('settings:actions.close'),
choices: [...(response?.data?.mergeable
? [{ value: 'merge', label: label ('edit.merge') }] : []),
{ value: 'overwrite', label: label ('edit.overwrite'), variant: 'danger' }] })
if (action === 'merge') if (action === 'merge')
{ {
@@ -165,13 +171,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
<PostTextField <PostTextField
label="タイトル" label={label ('shared:labels.title')}
value={title ?? ''} value={title ?? ''}
disabled={disabled} disabled={disabled}
onChange={setTitle}/> onChange={setTitle}/>
<PostTextField <PostTextField
label="親投稿" label={label ('shared:labels.parentPosts')}
value={parentPostIds} value={parentPostIds}
disabled={disabled} disabled={disabled}
errors={fieldErrors.parentPostIds} errors={fieldErrors.parentPostIds}
@@ -204,7 +210,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button type="submit" disabled={disabled}>
更新 {label ('shared:actions.update')}
</Button> </Button>
</form>) </form>)
} }
+1 -1
ファイルの表示
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useState } from 'react' import React, { useState } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type TabProps = { name: string type TabProps = { name: React.ReactNode
init?: boolean init?: boolean
children: React.ReactNode } children: React.ReactNode }
+2 -1
ファイルの表示
@@ -179,7 +179,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
closeRequest (active.id, active.kind !== 'confirm' && null) closeRequest (active.id, active.kind !== 'confirm' && null)
}}> }}>
<DialogContent <DialogContent
closeLabel={active.kind === 'confirm' ? active.options.closeLabel : undefined} closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
? active.options.closeLabel : undefined}
className={ className={
active.kind === 'form' active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large' ? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
+4 -3
ファイルの表示
@@ -16,13 +16,14 @@ type AlertOptions = { title: string
okText?: string } okText?: string }
type Choice<T extends string> = { value: T type Choice<T extends string> = { value: T
label: string label: ReactNode
variant?: DialogueVariant } variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string type ChoiceOptions<T extends string> = { title: ReactNode
description?: ReactNode description?: ReactNode
choices: Choice<T>[] choices: Choice<T>[]
cancelText?: string } cancelText?: ReactNode
closeLabel?: ReactNode }
type DialogueFormAction = { type DialogueFormAction = {
label: string label: string
+2 -2
ファイルの表示
@@ -2,8 +2,8 @@
"categoryLabel": "イソネカ", "categoryLabel": "イソネカ",
"stateLabel": "シㇼキ", "stateLabel": "シㇼキ",
"search": "イフナラ", "search": "イフナラ",
"loading": "タツ゚ㇺ アウㇰ コㇿ アン…", "loading": "タトゥㇺ アウㇰ コㇿ アン…",
"fetchError": "タツ゚ㇺ アウㇰ エアイカㇷ゚", "fetchError": "タトゥㇺ アウㇰ エアイカㇷ゚",
"states": { "active": "アㇱカイ", "states": { "active": "アㇱカイ",
"deprecated": "タネ ソモ アエイワンケ" }, "deprecated": "タネ ソモ アエイワンケ" },
"categories": { "deerjikist": "ユㇰ ニチカ ウタㇻ", "categories": { "deerjikist": "ユㇰ ニチカ ウタㇻ",
+20
ファイルの表示
@@ -0,0 +1,20 @@
{
"title": "素材管理",
"newTitle": "素材追加",
"detailTitle": "{{name}} 素材照会",
"unnamed": "素材 #{{id}}",
"media": { "image": "画像", "video": "動画", "audio": "音声", "file_other": "その他ファイル", "url_only": "URL のみ" },
"mediaFilter": { "all": "$t(navigation:list)", "image": "$t(materials:media.image)", "video": "$t(materials:media.video)", "audio": "$t(materials:media.audio)", "file_other": "$t(materials:media.file_other)", "url_only": "外部リンクのみ" },
"sort": { "created_at": "作成日時", "updated_at": "$t(tags:updatedAt)", "tag_name": "$t(history:tagName)", "media_kind": "種類", "file_byte_size": "容量", "version_no": "$t(history:version)", "id": "Id." },
"kindPrefix": "種類: ", "sizePrefix": "サイズ: ", "urlPrefix": "URL: ", "createdPrefix": "作成: ",
"other": "その他", "descendantScope": " 配下の素材を表示中", "tagScope": " の素材を表示中", "clearTag": "タグ選択を解除", "tagMissing": "選択中タグが見つかりません.", "searchTop": "素材検索トップへ戻る", "searchPlaceholder": "タグ名 / URL / ファイル名", "tagState": "タグ状態", "tagged": "タグあり", "mediaLabel": "メディア", "cardView": "アイコン", "listView": "詳細", "descending": "降順", "ascending": "昇順", "listError": "素材一覧の取得に失敗しました.", "empty": "素材はありません。", "pleaseAdd": "追加してください",
"file": "ファイル", "referenceUrl": "参考 URL", "exportPath": "ZIP 出力パス", "exportPlaceholder": "伊地知ニジカ/表情/泣き.png", "preview": "preview", "unsupportedFile": "その形式のファイルには対応していません.", "sent": "送信成功!", "sendFailed": "送信失敗……", "updated": "更新成功!", "updateFailed": "更新失敗……", "detailError": "素材の取得に失敗しました.", "missing": "素材が見つかりませんでした.",
"sidebar": { "label": "素材:", "present": "有", "missing": "無", "any": "全", "error": "タグ一覧の取得に失敗しました." },
"suppressions": {
"title": "素材同期抑止", "heading": "$t(navigation:suppressions)",
"sourceKind": "同期元種別", "sourceUri": "同期元 URI", "drivePath": "Google Drive パス", "driveFileId": "Google Drive ファイル Id.", "reason": "事由", "register": "登録", "reasonPrefix": "事由: ", "registeredPrefix": "登録: ", "registered": "同期元抑止を登録しました", "registerFailed": "同期元抑止の登録に失敗しました", "error": "同期元抑止一覧の取得に失敗しました.",
"sources": { "uri": "URI", "google_drive_path": "Google Drive ファイル", "google_drive_path_prefix": "Google Drive フォルダ", "google_drive_file": "Google Drive ファイル Id.", "legacy_drive_path": "汎用ファイル", "legacy_drive_path_prefix": "汎用フォルダ" },
"reasons": { "copyright_high_risk": "著作権への懸念", "copyright_takedown": "著作者からの申出", "adult_or_sensitive": "成人向け", "personal_information": "個人情報", "malware_or_dangerous_file": "危険なソフトウェア", "duplicate_or_low_quality": "重複", "source_owner_request": "同期元管理者からの申出", "other": "その他" }
},
"_untranslated": ["title", "newTitle", "detailTitle", "unnamed", "media", "mediaFilter.url_only", "sort.created_at", "sort.media_kind", "sort.file_byte_size", "kindPrefix", "sizePrefix", "createdPrefix", "other", "descendantScope", "tagScope", "clearTag", "tagMissing", "searchTop", "searchPlaceholder", "tagState", "tagged", "mediaLabel", "cardView", "listView", "descending", "ascending", "listError", "empty", "pleaseAdd", "file", "referenceUrl", "exportPath", "exportPlaceholder", "preview", "unsupportedFile", "sent", "sendFailed", "updated", "updateFailed", "detailError", "missing", "sidebar", "suppressions.title", "suppressions.sourceKind", "suppressions.sourceUri", "suppressions.drivePath", "suppressions.driveFileId", "suppressions.reason", "suppressions.register", "suppressions.reasonPrefix", "suppressions.registeredPrefix", "suppressions.registered", "suppressions.registerFailed", "suppressions.error", "suppressions.sources.google_drive_path", "suppressions.sources.google_drive_path_prefix", "suppressions.sources.google_drive_file", "suppressions.sources.legacy_drive_path", "suppressions.sources.legacy_drive_path_prefix", "suppressions.reasons"]
}
+11
ファイルの表示
@@ -0,0 +1,11 @@
{
"edit": {
"validationFailed": "更新はできなかったよ……",
"conflictTitle": "競合が発生しました.",
"conflictDescription": "ほかの耕作員が先に更新してゐます.",
"conflictQuestion": "現在の変更をどう扱ひますか?",
"merge": "差分をマージ",
"overwrite": "強制上書き"
},
"_untranslated": ["edit"]
}
+8
ファイルの表示
@@ -0,0 +1,8 @@
{
"title": "上映会場", "namedTitle": "上映会場『{{name}}』",
"layouts": { "threeColumns": "3 列", "tagsBottom": "2 列 A 型", "commentsBottom": "2 列 B 型" },
"flows": { "vertical": "縦並び", "horizontal": "横並び" },
"staff": "運営", "deleted": "削除されました.", "authorPrefix": "by ", "commentSuffix": " へのコメント",
"comments": "コメント", "commentPlaceholder": "ここにコメントを入力", "deleteComment": "コメントを削除", "confirmDelete": "このコメントを削除しますか?", "delete": "削除", "noTags": "タグはありません。", "participants": "参加者", "history": "再生履歴", "emptyHistory": "まだ履歴はありません。", "weights": "抽選重み", "unlikelyTags": "出にくいタグ", "emptyPenalties": "まだ減点はありません。", "unlikelyPosts": "出にくい候補", "likelyPosts": "出やすい候補", "watching": "同接 {{count}} 人", "cancelSkip": "スキップ取消", "selecting": "次の投稿を選んでゐます……", "waiting": "上映待機中", "playing": "再生中", "unselected": "未選択", "close": "閉じる", "editingPost": "編輯中の投稿", "editingSuffix": "を編輯中……", "emptyPosts": "候補はありません。", "penalty": "penalty {{value}}", "weight": "weight {{value}}",
"_untranslated": ["title", "namedTitle", "layouts", "flows", "staff", "deleted", "authorPrefix", "commentSuffix", "comments", "commentPlaceholder", "deleteComment", "confirmDelete", "delete", "noTags", "participants", "history", "emptyHistory", "weights", "unlikelyTags", "emptyPenalties", "unlikelyPosts", "likelyPosts", "watching", "cancelSkip", "selecting", "waiting", "playing", "unselected", "close", "editingPost", "editingSuffix", "emptyPosts", "penalty", "weight"]
}
+51
ファイルの表示
@@ -0,0 +1,51 @@
{
"title": "素材管理",
"newTitle": "素材追加",
"detailTitle": "{{name}} 素材照会",
"unnamed": "素材 #{{id}}",
"media": { "image": "画像", "video": "動画", "audio": "音声", "file_other": "その他ファイル", "url_only": "URL のみ" },
"mediaFilter": { "all": "すべて", "image": "$t(materials:media.image)", "video": "$t(materials:media.video)", "audio": "$t(materials:media.audio)", "file_other": "$t(materials:media.file_other)", "url_only": "外部リンクのみ" },
"sort": { "created_at": "作成日時", "updated_at": "更新日時", "tag_name": "タグ名", "media_kind": "種類", "file_byte_size": "容量", "version_no": "版", "id": "Id." },
"kindPrefix": "種類: ",
"sizePrefix": "サイズ: ",
"urlPrefix": "URL: ",
"createdPrefix": "作成: ",
"other": "その他",
"descendantScope": " 配下の素材を表示中",
"tagScope": " の素材を表示中",
"clearTag": "タグ選択を解除",
"tagMissing": "選択中タグが見つかりません.",
"searchTop": "素材検索トップへ戻る",
"searchPlaceholder": "タグ名 / URL / ファイル名",
"tagState": "タグ状態",
"tagged": "タグあり",
"mediaLabel": "メディア",
"cardView": "アイコン",
"listView": "詳細",
"descending": "降順",
"ascending": "昇順",
"listError": "素材一覧の取得に失敗しました.",
"empty": "素材はありません。",
"pleaseAdd": "追加してください",
"file": "ファイル",
"referenceUrl": "参考 URL",
"exportPath": "ZIP 出力パス",
"exportPlaceholder": "伊地知ニジカ/表情/泣き.png",
"preview": "preview",
"unsupportedFile": "その形式のファイルには対応していません.",
"sent": "送信成功!",
"sendFailed": "送信失敗……",
"updated": "更新成功!",
"updateFailed": "更新失敗……",
"detailError": "素材の取得に失敗しました.",
"missing": "素材が見つかりませんでした.",
"sidebar": { "label": "素材:", "present": "有", "missing": "無", "any": "全", "error": "タグ一覧の取得に失敗しました." },
"suppressions": {
"title": "素材同期抑止", "heading": "同期抑止",
"sourceKind": "同期元種別", "sourceUri": "同期元 URI", "drivePath": "Google Drive パス", "driveFileId": "Google Drive ファイル Id.",
"reason": "事由", "register": "登録", "reasonPrefix": "事由: ", "registeredPrefix": "登録: ",
"registered": "同期元抑止を登録しました", "registerFailed": "同期元抑止の登録に失敗しました", "error": "同期元抑止一覧の取得に失敗しました.",
"sources": { "uri": "URI", "google_drive_path": "Google Drive ファイル", "google_drive_path_prefix": "Google Drive フォルダ", "google_drive_file": "Google Drive ファイル Id.", "legacy_drive_path": "汎用ファイル", "legacy_drive_path_prefix": "汎用フォルダ" },
"reasons": { "copyright_high_risk": "著作権への懸念", "copyright_takedown": "著作者からの申出", "adult_or_sensitive": "成人向け", "personal_information": "個人情報", "malware_or_dangerous_file": "危険なソフトウェア", "duplicate_or_low_quality": "重複", "source_owner_request": "同期元管理者からの申出", "other": "その他" }
}
}
+10
ファイルの表示
@@ -0,0 +1,10 @@
{
"edit": {
"validationFailed": "更新はできなかったよ……",
"conflictTitle": "競合が発生しました.",
"conflictDescription": "ほかの耕作員が先に更新してゐます.",
"conflictQuestion": "現在の変更をどう扱ひますか?",
"merge": "差分をマージ",
"overwrite": "強制上書き"
}
}
+7
ファイルの表示
@@ -0,0 +1,7 @@
{
"title": "上映会場", "namedTitle": "上映会場『{{name}}』",
"layouts": { "threeColumns": "3 列", "tagsBottom": "2 列 A 型", "commentsBottom": "2 列 B 型" },
"flows": { "vertical": "縦並び", "horizontal": "横並び" },
"staff": "運営", "deleted": "削除されました.", "authorPrefix": "by ", "commentSuffix": " へのコメント",
"comments": "コメント", "commentPlaceholder": "ここにコメントを入力", "deleteComment": "コメントを削除", "confirmDelete": "このコメントを削除しますか?", "delete": "削除", "noTags": "タグはありません。", "participants": "参加者", "history": "再生履歴", "emptyHistory": "まだ履歴はありません。", "weights": "抽選重み", "unlikelyTags": "出にくいタグ", "emptyPenalties": "まだ減点はありません。", "unlikelyPosts": "出にくい候補", "likelyPosts": "出やすい候補", "watching": "同接 {{count}} 人", "cancelSkip": "スキップ取消", "selecting": "次の投稿を選んでゐます……", "waiting": "上映待機中", "playing": "再生中", "unselected": "未選択", "close": "閉じる", "editingPost": "編輯中の投稿", "editingSuffix": "を編輯中……", "emptyPosts": "候補はありません。", "penalty": "penalty {{value}}", "weight": "weight {{value}}"
}
+21 -17
ファイルの表示
@@ -22,6 +22,7 @@ import {
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react' import type { FC } from 'react'
@@ -30,6 +31,7 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialDetailPage: FC = () => { const MaterialDetailPage: FC = () => {
const locale = useLocale () const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const { id } = useParams () const { id } = useParams ()
const qc = useQueryClient () const qc = useQueryClient ()
@@ -46,7 +48,7 @@ const MaterialDetailPage: FC = () => {
queryFn: () => fetchMaterial (id ?? '', locale), queryFn: () => fetchMaterial (id ?? '', locale),
enabled: id != null}) enabled: id != null})
const materialTitle = material const materialTitle = material
? material.tag?.name ?? `素材 #${ material.id }` ? material.tag?.name ?? message ('unnamed', { id: material.id }).text
: '' : ''
useEffect (() => { useEffect (() => {
@@ -83,11 +85,11 @@ const MaterialDetailPage: FC = () => {
onSuccess: async data => { onSuccess: async data => {
qc.setQueryData (materialsKeys.show (id ?? '', locale), data) qc.setQueryData (materialsKeys.show (id ?? '', locale), data)
await invalidateMaterialQueries () await invalidateMaterialQueries ()
toast ({ title: '更新成功!' }) toast ({ title: label ('updated') })
}, },
onError: error => { onError: error => {
applyValidationError (error) applyValidationError (error)
toast ({ title: '更新失敗……', description: '入力を見直してください.' }) toast ({ title: label ('updateFailed'), description: label ('shared:messages.reviewInput') })
}}) }})
const handleSubmit = () => { const handleSubmit = () => {
@@ -99,15 +101,15 @@ const MaterialDetailPage: FC = () => {
<MainArea> <MainArea>
{material && ( {material && (
<Helmet> <Helmet>
<title>{`${ materialTitle } 素材照会 | ${ SITE_TITLE }`}</title> <title>{message ('detailTitle', { name: materialTitle }).text} | {SITE_TITLE}</title>
</Helmet>)} </Helmet>)}
{isLoading ? 'Loading...' : isError ? ( {isLoading ? label ('common:loading') : isError ? (
<p className="text-red-600 dark:text-red-300"> <p className="text-red-600 dark:text-red-300">
素材の取得に失敗しました. {label ('detailError')}
</p>) : material == null ? ( </p>) : material == null ? (
<p className="text-stone-700 dark:text-stone-300"> <p className="text-stone-700 dark:text-stone-300">
素材が見つかりませんでした. {label ('missing')}
</p>) : ( </p>) : (
<> <>
<PageTitle> <PageTitle>
@@ -117,7 +119,7 @@ const MaterialDetailPage: FC = () => {
tag={material.tag} tag={material.tag}
withWiki={false} withWiki={false}
withCount={false}/>) withCount={false}/>)
: materialTitle} : label ('unnamed', { id: material.id })}
</PageTitle> </PageTitle>
{(material.file && material.contentType) && ( {(material.file && material.contentType) && (
@@ -136,11 +138,11 @@ const MaterialDetailPage: FC = () => {
body={material.wikiPageBody ?? undefined}/> body={material.wikiPageBody ?? undefined}/>
</Tab>)} </Tab>)}
<Tab name="編輯"> <Tab name={label ('shared:actions.edit')}>
<div className="max-w-wl space-y-4 pt-2"> <div className="max-w-wl space-y-4 pt-2">
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
<FormField label="タグ" messages={fieldErrors.tag}> <FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<TagInput <TagInput
describedBy={describedBy} describedBy={describedBy}
@@ -149,7 +151,7 @@ const MaterialDetailPage: FC = () => {
setValue={setTag}/>)} setValue={setTag}/>)}
</FormField> </FormField>
<FormField label="ファイル" messages={fieldErrors.file}> <FormField label={label ('file')} messages={fieldErrors.file}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<input <input
@@ -167,7 +169,8 @@ const MaterialDetailPage: FC = () => {
(/image\/.*/.test (file.type) && ( (/image\/.*/.test (file.type) && (
<img <img
src={filePreview} src={filePreview}
alt="preview" alt={message ('preview').text}
lang={message ('preview').language}
className="mt-2 max-h-48 rounded border"/>)) className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && ( || (/video\/.*/.test (file.type) && (
<video <video
@@ -181,12 +184,12 @@ const MaterialDetailPage: FC = () => {
className="mt-2 max-h-48"/>)) className="mt-2 max-h-48"/>))
|| ( || (
<p className="text-red-600 dark:text-red-400"> <p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません. {label ('unsupportedFile')}
</p>))} </p>))}
</>)} </>)}
</FormField> </FormField>
<FormField label="参考 URL" messages={fieldErrors.url}> <FormField label={label ('referenceUrl')} messages={fieldErrors.url}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
type="url" type="url"
@@ -197,13 +200,14 @@ const MaterialDetailPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField label="ZIP 出力パス" messages={fieldErrors.exportPaths}> <FormField label={label ('exportPath')} messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
type="text" type="text"
value={exportPath} value={exportPath}
onChange={e => setExportPath (e.target.value)} onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png" placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
@@ -215,7 +219,7 @@ const MaterialDetailPage: FC = () => {
className="rounded bg-blue-600 px-4 py-2 text-white className="rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400" disabled:bg-gray-400"
disabled={updateMutation.isPending}> disabled={updateMutation.isPending}>
更新 {label ('shared:actions.update')}
</Button> </Button>
</div> </div>
</div> </div>
+99 -74
ファイルの表示
@@ -6,6 +6,7 @@ import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale' import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import nikumaru from '@/assets/fonts/nikumaru.otf' import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
@@ -14,7 +15,8 @@ import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { fetchMaterials, parseMaterialFilter } from '@/lib/materials' import { fetchMaterials, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, localisedDateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
@@ -30,28 +32,28 @@ import type { FetchMaterialsParams,
MaterialIndexView } from '@/types' MaterialIndexView } from '@/types'
const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = { const MEDIA_KIND_LABELS: Record<Material['mediaKind'], string> = {
image: '画像', image: 'media.image',
video: '動画', video: 'media.video',
audio: '音声', audio: 'media.audio',
file_other: 'その他ファイル', file_other: 'media.file_other',
url_only: 'URL のみ'} url_only: 'media.url_only' }
const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = { const MEDIA_FILTER_LABELS: Record<MaterialIndexMediaKind, string> = {
all: 'すべて', all: 'mediaFilter.all',
image: '画像', image: 'mediaFilter.image',
video: '動画', video: 'mediaFilter.video',
audio: '音声', audio: 'mediaFilter.audio',
file_other: 'その他ファイル', file_other: 'mediaFilter.file_other',
url_only: '外部リンクのみ'} url_only: 'mediaFilter.url_only' }
const SORT_LABELS: Record<MaterialIndexSort, string> = { const SORT_LABELS: Record<MaterialIndexSort, string> = {
created_at: '作成日時', created_at: 'sort.created_at',
updated_at: '更新日時', updated_at: 'sort.updated_at',
tag_name: 'タグ名', tag_name: 'sort.tag_name',
media_kind: '種類', media_kind: 'sort.media_kind',
file_byte_size: '容量', file_byte_size: 'sort.file_byte_size',
version_no: '版', version_no: 'sort.version_no',
id: 'Id.'} id: 'sort.id' }
const setIf = (qs: URLSearchParams, key: string, value: string | null) => { const setIf = (qs: URLSearchParams, key: string, value: string | null) => {
@@ -78,10 +80,6 @@ const fileSizeText = (bytes: number | null): string => {
} }
const materialTitle = (material: Material): string =>
material.tag?.name ?? `素材 #${ material.id }`
const groupedTagPath = ( const groupedTagPath = (
tagId: number, tagId: number,
materialFilter: MaterialFilter, materialFilter: MaterialFilter,
@@ -132,27 +130,37 @@ const MaterialThumb: FC<{ material: Material }> = ({ material }) => (
</div>) </div>)
const MaterialCard: FC<{ material: Material }> = ({ material }) => ( const MaterialCard: FC<{ material: Material }> = ({ material }) => {
const locale = useLocale ()
const { label } = useUITranslation ('materials')
return (
<article className="w-[180px] justify-self-center"> <article className="w-[180px] justify-self-center">
<PrefetchLink to={`/materials/${ material.id }`} className="block"> <PrefetchLink to={`/materials/${ material.id }`} className="block">
<MaterialThumb material={material}/> <MaterialThumb material={material}/>
<div className="mt-2 w-[180px]"> <div className="mt-2 w-[180px]">
<p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100"> <p className="truncate text-sm font-medium text-stone-900 dark:text-stone-100">
{materialTitle (material)} {material.tag?.name ?? label ('unnamed', { id: material.id })}
</p> </p>
<p className="truncate text-xs text-stone-600 dark:text-stone-300"> <p className="text-xs text-stone-600 dark:text-stone-300">
{MEDIA_KIND_LABELS[material.mediaKind]} / {dateString (material.createdAt)} {label (MEDIA_KIND_LABELS[material.mediaKind])} /{' '}
<LocalisedText language={locale}>
{localisedDateString (material.createdAt, locale)}
</LocalisedText>
</p> </p>
</div> </div>
</PrefetchLink> </PrefetchLink>
</article>) </article>)
}
const MaterialListItem: FC<{ material: Material }> = ({ material }) => ( const MaterialListItem: FC<{ material: Material }> = ({ material }) => {
const locale = useLocale ()
const { label } = useUITranslation ('materials')
return (
<article <article
className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900 className="rounded-lg border border-stone-200 bg-white p-3 text-stone-900
shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100"> shadow-sm dark:border-stone-700 dark:bg-stone-900 dark:text-stone-100">
<div className="flex gap-3"> <div className="flex flex-wrap gap-3">
<MaterialThumb material={material}/> <MaterialThumb material={material}/>
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div> <div>
@@ -160,58 +168,66 @@ const MaterialListItem: FC<{ material: Material }> = ({ material }) => (
to={`/materials/${ material.id }`} to={`/materials/${ material.id }`}
className="font-medium text-sky-700 underline underline-offset-2 className="font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300"> dark:text-sky-300">
{materialTitle (material)} {material.tag?.name ?? label ('unnamed', { id: material.id })}
</PrefetchLink> </PrefetchLink>
</div> </div>
<dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300"> <dl className="space-y-1 text-sm text-stone-600 dark:text-stone-300">
<div> <div>
<dt className="inline">種類: </dt> <dt className="inline">{label ('kindPrefix')}</dt>
<dd className="inline">{MEDIA_KIND_LABELS[material.mediaKind]}</dd> <dd className="inline">{label (MEDIA_KIND_LABELS[material.mediaKind])}</dd>
</div> </div>
{material.fileByteSize != null && ( {material.fileByteSize != null && (
<div> <div>
<dt className="inline">サイズ: </dt> <dt className="inline">{label ('sizePrefix')}</dt>
<dd className="inline">{fileSizeText (material.fileByteSize)}</dd> <dd className="inline">{fileSizeText (material.fileByteSize)}</dd>
</div>)} </div>)}
{material.url && ( {material.url && (
<div> <div>
<dt className="inline">URL: </dt> <dt className="inline">{label ('urlPrefix')}</dt>
<dd className="inline break-all">{material.url}</dd> <dd className="inline break-all">{material.url}</dd>
</div>)} </div>)}
<div> <div>
<dt className="inline">作成: </dt> <dt className="inline">{label ('createdPrefix')}</dt>
<dd className="inline">{dateString (material.createdAt)}</dd> <dd className="inline">
<LocalisedText language={locale}>
{localisedDateString (material.createdAt, locale)}
</LocalisedText>
</dd>
</div> </div>
</dl> </dl>
</div> </div>
</div> </div>
</article>) </article>)
}
const GroupHeading: FC<{ const GroupHeading: FC<{
count: number count: number
materialFilter: MaterialFilter materialFilter: MaterialFilter
title: string title: string
tagId: number tagId: number }> = ({ count, materialFilter, title, tagId }) => {
}> = ({ count, materialFilter, title, tagId }) => ( const { label } = useUITranslation ('materials')
<div className="flex items-center gap-2 border-b border-stone-200 pb-2 return (
<div className="flex flex-wrap items-center gap-2 border-b border-stone-200 pb-2
dark:border-stone-700"> dark:border-stone-700">
<PrefetchLink <PrefetchLink
to={groupedTagPath (tagId, materialFilter)} to={groupedTagPath (tagId, materialFilter)}
className="font-medium text-sky-700 underline underline-offset-2 className="min-w-0 flex-1 font-medium text-sky-700 underline underline-offset-2
dark:text-sky-300 w-full"> dark:text-sky-300">
{title} {title}
</PrefetchLink> </PrefetchLink>
<div className="text-right w-auto"> <div className="text-right w-auto">
<span className="text-nowrap text-sm text-stone-600 dark:text-stone-300"> <span className="text-sm text-stone-600 dark:text-stone-300">
{count} 件 {label ('shared:labels.resultsCount', { count })}
</span> </span>
</div> </div>
</div>) </div>)
}
const MaterialListPage: FC = () => { const MaterialListPage: FC = () => {
const locale = useLocale () const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const location = useLocation () const location = useLocation ()
const navigate = useLocaleNavigate () const navigate = useLocaleNavigate ()
const query = useMemo (() => new URLSearchParams (location.search), [location.search]) const query = useMemo (() => new URLSearchParams (location.search), [location.search])
@@ -387,7 +403,7 @@ const MaterialListPage: FC = () => {
<section className="space-y-3"> <section className="space-y-3">
<div className="border-b border-stone-200 pb-2 dark:border-stone-700"> <div className="border-b border-stone-200 pb-2 dark:border-stone-700">
<span className="font-medium text-stone-900 dark:text-stone-100"> <span className="font-medium text-stone-900 dark:text-stone-100">
その他 {label ('other')}
</span> </span>
</div> </div>
{renderMaterialCollection (ungroupedMaterials)} {renderMaterialCollection (ungroupedMaterials)}
@@ -405,12 +421,12 @@ const MaterialListPage: FC = () => {
src: url(${ nikumaru }) format('opentype'); src: url(${ nikumaru }) format('opentype');
}`} }`}
</style> </style>
<title>{`素材管理 | ${ SITE_TITLE }`}</title> <title>{message ('title').text} | {SITE_TITLE}</title>
</Helmet> </Helmet>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle className="my-auto">素材管理</PageTitle> <PageTitle className="my-auto">{label ('title')}</PageTitle>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{/* TODO: 局所出力を可能にする */} {/* TODO: 局所出力を可能にする */}
{/* <a {/* <a
@@ -431,25 +447,25 @@ const MaterialListPage: FC = () => {
dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100"> dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100">
<span> <span>
{tagScope.tag.name} {tagScope.tag.name}
{tagScope.includeDescendants ? ' 配下の素材を表示中' : ' の素材を表示中'} {label (tagScope.includeDescendants ? 'descendantScope' : 'tagScope')}
</span> </span>
<PrefetchLink <PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)} to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2 className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300"> text-sky-700 dark:text-sky-300">
タグ選択を解除 {label ('clearTag')}
</PrefetchLink> </PrefetchLink>
</div>)} </div>)}
{(tagId != null && tagScope == null) && ( {(tagId != null && tagScope == null) && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border <div className="flex flex-wrap items-center gap-3 rounded-lg border
border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900
dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"> dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100">
<span>選択中タグが見つかりません.</span> <span>{label ('tagMissing')}</span>
<PrefetchLink <PrefetchLink
to={clearedTagSelectionPath (location.search, materialFilter)} to={clearedTagSelectionPath (location.search, materialFilter)}
className="font-medium underline underline-offset-2 className="font-medium underline underline-offset-2
text-amber-800 dark:text-amber-200"> text-amber-800 dark:text-amber-200">
タグ選択を解除 {label ('clearTag')}
</PrefetchLink> </PrefetchLink>
</div>)} </div>)}
@@ -457,7 +473,7 @@ const MaterialListPage: FC = () => {
<PrefetchLink <PrefetchLink
to={`/materials?material_filter=${ materialFilter }`} to={`/materials?material_filter=${ materialFilter }`}
className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300"> className="text-sm text-sky-700 underline underline-offset-2 dark:text-sky-300">
素材検索トップへ戻る {label ('searchTop')}
</PrefetchLink>)} </PrefetchLink>)}
<form <form
@@ -466,44 +482,49 @@ const MaterialListPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900 text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100"> dark:text-stone-100">
<div className="space-y-3"> <div className="space-y-3">
<FormField label="検索"> <FormField label={label ('common:search')}>
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="search" type="search"
value={q} value={q}
onChange={e => setQ (e.target.value)} onChange={e => setQ (e.target.value)}
placeholder="タグ名 / URL / ファイル名" placeholder={message ('searchPlaceholder').text}
lang={message ('searchPlaceholder').language}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField label="タグ状態"> <FormField label={label ('tagState')}>
{({ invalid }) => ( {({ invalid }) => (
<select <select
value={tagStateInput} value={tagStateInput}
onChange={e => setTagStateInput ( onChange={e => setTagStateInput (
e.target.value as MaterialIndexTagState)} e.target.value as MaterialIndexTagState)}
className={inputClass (invalid)}> className={inputClass (invalid)}>
<option value="all">すべて</option> <option value="all" lang={message ('mediaFilter.all').language}>
<option value="tagged">タグあり</option> {message ('mediaFilter.all').text}
</option>
<option value="tagged" lang={message ('tagged').language}>
{message ('tagged').text}
</option>
<option value="untagged"></option> <option value="untagged"></option>
</select>)} </select>)}
</FormField> </FormField>
<FormField label="メディア"> <FormField label={label ('mediaLabel')}>
{({ invalid }) => ( {({ invalid }) => (
<select <select
value={mediaKindInput} value={mediaKindInput}
onChange={e => setMediaKindInput ( onChange={e => setMediaKindInput (
e.target.value as MaterialIndexMediaKind)} e.target.value as MaterialIndexMediaKind)}
className={inputClass (invalid)}> className={inputClass (invalid)}>
{Object.entries (MEDIA_FILTER_LABELS).map (([value, label]) => ( {Object.entries (MEDIA_FILTER_LABELS).map (([value, key]) => (
<option key={value} value={value}> <option key={value} value={value} lang={message (key).language}>
{label} {message (key).text}
</option>))} </option>))}
</select>)} </select>)}
</FormField> </FormField>
<FormField label="作成日時"> <FormField label={label ('sort.created_at')}>
{() => ( {() => (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<DateTimeField value={createdFrom ?? undefined} onChange={setCreatedFrom}/> <DateTimeField value={createdFrom ?? undefined} onChange={setCreatedFrom}/>
@@ -512,7 +533,7 @@ const MaterialListPage: FC = () => {
</div>)} </div>)}
</FormField> </FormField>
<FormField label="更新日時"> <FormField label={label ('sort.updated_at')}>
{() => ( {() => (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<DateTimeField value={updatedFrom ?? undefined} onChange={setUpdatedFrom}/> <DateTimeField value={updatedFrom ?? undefined} onChange={setUpdatedFrom}/>
@@ -527,7 +548,7 @@ const MaterialListPage: FC = () => {
type="submit" type="submit"
className="rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 className="rounded bg-sky-600 px-4 py-2 text-white hover:bg-sky-700
dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400"> dark:bg-sky-500 dark:text-stone-950 dark:hover:bg-sky-400">
検索 {label ('common:search')}
</button> </button>
</div> </div>
</form> </form>
@@ -546,7 +567,7 @@ const MaterialListPage: FC = () => {
: [ : [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}> 'dark:bg-stone-900 dark:text-stone-100'])}>
アイコン {label ('cardView')}
</button> </button>
<button <button
type="button" type="button"
@@ -560,7 +581,7 @@ const MaterialListPage: FC = () => {
: [ : [
'border-stone-300 bg-white text-stone-900 dark:border-stone-700', 'border-stone-300 bg-white text-stone-900 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-100'])}> 'dark:bg-stone-900 dark:text-stone-100'])}>
詳細 {label ('listView')}
</button> </button>
</div> </div>
@@ -569,34 +590,38 @@ const MaterialListPage: FC = () => {
value={sort} value={sort}
onChange={e => updateQuery ({ sort: e.target.value, page: '1' })} onChange={e => updateQuery ({ sort: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}> className={inputClass (false, 'w-auto')}>
{Object.entries (SORT_LABELS).map (([value, label]) => ( {Object.entries (SORT_LABELS).map (([value, key]) => (
<option key={value} value={value}> <option key={value} value={value} lang={message (key).language}>
{label} {message (key).text}
</option>))} </option>))}
</select> </select>
<select <select
value={direction} value={direction}
onChange={e => updateQuery ({ direction: e.target.value, page: '1' })} onChange={e => updateQuery ({ direction: e.target.value, page: '1' })}
className={inputClass (false, 'w-auto')}> className={inputClass (false, 'w-auto')}>
<option value="desc">降順</option> <option value="desc" lang={message ('descending').language}>
<option value="asc">昇順</option> {message ('descending').text}
</option>
<option value="asc" lang={message ('ascending').language}>
{message ('ascending').text}
</option>
</select> </select>
</div> </div>
</div> </div>
{isLoading && <p>Loading...</p>} {isLoading && <p>{label ('common:loading')}</p>}
{isError && ( {isError && (
<p className="text-red-600 dark:text-red-300">素材一覧の取得に失敗しました.</p>)} <p className="text-red-600 dark:text-red-300">{label ('listError')}</p>)}
{(!isLoading && !isError && materials.length === 0) && ( {(!isLoading && !isError && materials.length === 0) && (
<p> <p>
素材はありません。 {label ('empty')}
{(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && ( {(tagScope && ['character', 'material'].includes (tagScope.tag.category)) && (
<> <>
<PrefetchLink <PrefetchLink
to={materialNewPath (tagScope.tag.name, location.pathname + location.search)} to={materialNewPath (tagScope.tag.name, location.pathname + location.search)}
className="font-medium underline underline-offset-2 className="font-medium underline underline-offset-2
text-sky-700 dark:text-sky-300"> text-sky-700 dark:text-sky-300">
追加してください {label ('pleaseAdd')}
</PrefetchLink>。 </PrefetchLink>。
</>)} </>)}
</p>)} </p>)}
+16 -12
ファイルの表示
@@ -18,6 +18,7 @@ import { unlocalisedPath } from '@/lib/locales'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { inputClass } from '@/lib/utils' import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react' import type { FC } from 'react'
@@ -26,6 +27,7 @@ type MaterialFormField = 'tag' | 'file' | 'url' | 'exportPaths'
const MaterialNewPage: FC = () => { const MaterialNewPage: FC = () => {
const locale = useLocale () const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const qc = useQueryClient () const qc = useQueryClient ()
const location = useLocation () const location = useLocation ()
const query = new URLSearchParams (location.search) const query = new URLSearchParams (location.search)
@@ -59,12 +61,12 @@ const MaterialNewPage: FC = () => {
}, },
onSuccess: async () => { onSuccess: async () => {
await qc.invalidateQueries ({ queryKey: materialsKeys.root }) await qc.invalidateQueries ({ queryKey: materialsKeys.root })
toast ({ title: '送信成功!' }) toast ({ title: label ('sent') })
navigate (safeReturnTo || '/materials') navigate (safeReturnTo || '/materials')
}, },
onError: error => { onError: error => {
applyValidationError (error) applyValidationError (error)
toast ({ title: '送信失敗……', description: '入力を見直してください.' }) toast ({ title: label ('sendFailed'), description: label ('shared:messages.reviewInput') })
}}) }})
const handleSubmit = () => { const handleSubmit = () => {
@@ -75,15 +77,15 @@ const MaterialNewPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title> <title>{message ('newTitle').text} | {SITE_TITLE}</title>
</Helmet> </Helmet>
<Form> <Form>
<PageTitle>素材追加</PageTitle> <PageTitle>{label ('newTitle')}</PageTitle>
<FieldError messages={baseErrors}/> <FieldError messages={baseErrors}/>
{/* タグ */} {/* タグ */}
<FormField label="タグ" messages={fieldErrors.tag}> <FormField label={label ('navigation:tags')} messages={fieldErrors.tag}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<TagInput <TagInput
describedBy={describedBy} describedBy={describedBy}
@@ -93,7 +95,7 @@ const MaterialNewPage: FC = () => {
</FormField> </FormField>
{/* ファイル */} {/* ファイル */}
<FormField label="ファイル" messages={fieldErrors.file}> <FormField label={label ('file')} messages={fieldErrors.file}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<> <>
<input <input
@@ -110,7 +112,8 @@ const MaterialNewPage: FC = () => {
(/image\/.*/.test (file.type) && ( (/image\/.*/.test (file.type) && (
<img <img
src={filePreview} src={filePreview}
alt="preview" alt={message ('preview').text}
lang={message ('preview').language}
className="mt-2 max-h-48 rounded border"/>)) className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && ( || (/video\/.*/.test (file.type) && (
<video <video
@@ -124,13 +127,13 @@ const MaterialNewPage: FC = () => {
className="mt-2 max-h-48"/>)) className="mt-2 max-h-48"/>))
|| ( || (
<p className="text-red-600 dark:text-red-400"> <p className="text-red-600 dark:text-red-400">
その形式のファイルには対応していません. {label ('unsupportedFile')}
</p>))} </p>))}
</>)} </>)}
</FormField> </FormField>
{/* 参考 URL */} {/* 参考 URL */}
<FormField label="参考 URL" messages={fieldErrors.url}> <FormField label={label ('referenceUrl')} messages={fieldErrors.url}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
type="url" type="url"
@@ -142,14 +145,15 @@ const MaterialNewPage: FC = () => {
</FormField> </FormField>
<FormField <FormField
label="ZIP 出力パス" label={label ('exportPath')}
messages={fieldErrors.exportPaths}> messages={fieldErrors.exportPaths}>
{({ describedBy, invalid }) => ( {({ describedBy, invalid }) => (
<input <input
type="text" type="text"
value={exportPath} value={exportPath}
onChange={e => setExportPath (e.target.value)} onChange={e => setExportPath (e.target.value)}
placeholder="伊地知ニジカ/表情/泣き.png" placeholder={message ('exportPlaceholder').text}
lang={message ('exportPlaceholder').language}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-invalid={invalid} aria-invalid={invalid}
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
@@ -160,7 +164,7 @@ const MaterialNewPage: FC = () => {
onClick={handleSubmit} onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={createMutation.isPending}> disabled={createMutation.isPending}>
追加 {label ('shared:actions.add')}
</Button> </Button>
</Form> </Form>
</MainArea>) </MainArea>)
+36 -42
ファイルの表示
@@ -11,19 +11,20 @@ import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials' import { createMaterialSyncSuppression, fetchMaterialSyncSuppressions } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys' import { materialsKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils' import { localisedDateString, inputClass } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { MaterialSyncSuppressionSourceKind } from '@/types' import type { MaterialSyncSuppressionSourceKind } from '@/types'
const SOURCE_KIND_LABELS: Record<MaterialSyncSuppressionSourceKind, string> = { const SOURCE_KIND_LABELS: Record<MaterialSyncSuppressionSourceKind, string> = {
uri: 'URI', uri: 'suppressions.sources.uri',
google_drive_path: 'Google Drive ファイル', google_drive_path: 'suppressions.sources.google_drive_path',
google_drive_path_prefix: 'Google Drive フォルダ', google_drive_path_prefix: 'suppressions.sources.google_drive_path_prefix',
google_drive_file: 'Google Drive ファイル Id.', google_drive_file: 'suppressions.sources.google_drive_file',
legacy_drive_path: '汎用ファイル', legacy_drive_path: 'suppressions.sources.legacy_drive_path',
legacy_drive_path_prefix: '汎用フォルダ' } legacy_drive_path_prefix: 'suppressions.sources.legacy_drive_path_prefix' }
const REASONS = [ const REASONS = [
'copyright_high_risk', 'copyright_high_risk',
@@ -37,23 +38,9 @@ const REASONS = [
type MaterialSyncSuppressionReason = typeof REASONS[number] type MaterialSyncSuppressionReason = typeof REASONS[number]
const REASON_NAMES: Record<MaterialSyncSuppressionReason, string> = {
['copyright_high_risk']: '著作権への懸念',
['copyright_takedown']: '著作者からの申出',
['adult_or_sensitive']: '成人向け',
['personal_information']: '個人情報',
['malware_or_dangerous_file']: '危険なソフトウェア',
['duplicate_or_low_quality']: '重複',
['source_owner_request']: '同期元管理者からの申出',
['other']: 'その他' } as const
const reasonName = (reason: string): string =>
REASON_NAMES[reason as MaterialSyncSuppressionReason] ?? reason
const MaterialSyncSuppressionsPage: FC = () => { const MaterialSyncSuppressionsPage: FC = () => {
const locale = useLocale () const locale = useLocale ()
const { message, label } = useUITranslation ('materials')
const qc = useQueryClient () const qc = useQueryClient ()
const [sourceKind, setSourceKind] = const [sourceKind, setSourceKind] =
useState<MaterialSyncSuppressionSourceKind> ('uri') useState<MaterialSyncSuppressionSourceKind> ('uri')
@@ -80,10 +67,10 @@ const MaterialSyncSuppressionsPage: FC = () => {
setSourceUri ('') setSourceUri ('')
setDrivePath ('') setDrivePath ('')
setDriveFileId ('') setDriveFileId ('')
toast ({ title: '同期元抑止を登録しました' }) toast ({ title: label ('suppressions.registered') })
}, },
onError: () => { onError: () => {
toast ({ title: '同期元抑止の登録に失敗しました' }) toast ({ title: label ('suppressions.registerFailed') })
}}) }})
const handleSubmit = (event: FormEvent) => { const handleSubmit = (event: FormEvent) => {
@@ -96,11 +83,11 @@ const MaterialSyncSuppressionsPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet> <Helmet>
<title>{`素材同期抑止 | ${ SITE_TITLE }`}</title> <title>{message ('suppressions.title').text} | {SITE_TITLE}</title>
</Helmet> </Helmet>
<div className="space-y-5"> <div className="space-y-5">
<PageTitle>同期抑止</PageTitle> <PageTitle>{label ('suppressions.heading')}</PageTitle>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -108,21 +95,21 @@ const MaterialSyncSuppressionsPage: FC = () => {
text-stone-900 dark:border-stone-700 dark:bg-stone-900 text-stone-900 dark:border-stone-700 dark:bg-stone-900
dark:text-stone-100"> dark:text-stone-100">
<div className="space-y-3"> <div className="space-y-3">
<FormField label="同期元種別"> <FormField label={label ('suppressions.sourceKind')}>
{({ invalid }) => ( {({ invalid }) => (
<select <select
value={sourceKind} value={sourceKind}
onChange={e => setSourceKind ( onChange={e => setSourceKind (
e.target.value as MaterialSyncSuppressionSourceKind)} e.target.value as MaterialSyncSuppressionSourceKind)}
className={inputClass (invalid)}> className={inputClass (invalid)}>
{Object.entries (SOURCE_KIND_LABELS).map (([value, label]) => ( {Object.entries (SOURCE_KIND_LABELS).map (([value, key]) => (
<option key={value} value={value}> <option key={value} value={value} lang={message (key).language}>
{label} {message (key).text}
</option>))} </option>))}
</select>)} </select>)}
</FormField> </FormField>
<FormField label="同期元 URI"> <FormField label={label ('suppressions.sourceUri')}>
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
@@ -131,7 +118,7 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField label="Google Drive パス"> <FormField label={label ('suppressions.drivePath')}>
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
@@ -140,7 +127,7 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField label="Google Drive ファイル Id."> <FormField label={label ('suppressions.driveFileId')}>
{({ invalid }) => ( {({ invalid }) => (
<input <input
type="text" type="text"
@@ -149,15 +136,16 @@ const MaterialSyncSuppressionsPage: FC = () => {
className={inputClass (invalid)}/>)} className={inputClass (invalid)}/>)}
</FormField> </FormField>
<FormField label="事由"> <FormField label={label ('suppressions.reason')}>
{({ invalid }) => ( {({ invalid }) => (
<select <select
value={reason} value={reason}
onChange={e => setReason (e.target.value as MaterialSyncSuppressionReason)} onChange={e => setReason (e.target.value as MaterialSyncSuppressionReason)}
className={inputClass (invalid)}> className={inputClass (invalid)}>
{REASONS.map (value => ( {REASONS.map (value => (
<option key={value} value={value}> <option key={value} value={value}
{REASON_NAMES[value]} lang={message (`suppressions.reasons.${value}`).language}>
{message (`suppressions.reasons.${value}`).text}
</option>))} </option>))}
</select>)} </select>)}
</FormField> </FormField>
@@ -168,14 +156,14 @@ const MaterialSyncSuppressionsPage: FC = () => {
className="mt-4 rounded bg-blue-600 px-4 py-2 text-white className="mt-4 rounded bg-blue-600 px-4 py-2 text-white
disabled:bg-gray-400" disabled:bg-gray-400"
disabled={createMutation.isPending}> disabled={createMutation.isPending}>
登録 {label ('suppressions.register')}
</Button> </Button>
</form> </form>
{isLoading && <p>Loading...</p>} {isLoading && <p>{label ('common:loading')}</p>}
{isError && ( {isError && (
<p className="text-red-600 dark:text-red-300"> <p className="text-red-600 dark:text-red-300">
同期元抑止一覧の取得に失敗しました. {label ('suppressions.error')}
</p>)} </p>)}
<div className="space-y-3"> <div className="space-y-3">
@@ -186,14 +174,20 @@ const MaterialSyncSuppressionsPage: FC = () => {
text-stone-900 shadow-sm dark:border-stone-700 text-stone-900 shadow-sm dark:border-stone-700
dark:bg-stone-900 dark:text-stone-100"> dark:bg-stone-900 dark:text-stone-100">
<div className="font-medium"> <div className="font-medium">
{SOURCE_KIND_LABELS[suppression.sourceKind]} {label (SOURCE_KIND_LABELS[suppression.sourceKind])}
</div> </div>
<div className="break-all text-sm text-stone-700 dark:text-stone-300"> <div className="break-all text-sm text-stone-700 dark:text-stone-300">
{suppression.normalizedSourceKey} {suppression.normalizedSourceKey}
</div> </div>
<div className="mt-2 text-sm text-stone-600 dark:text-stone-400"> <div className="mt-2 text-sm text-stone-600 dark:text-stone-400">
事由: {reasonName (suppression.reason)} / {label ('suppressions.reasonPrefix')}
登録: {dateString (suppression.createdAt)} {REASONS.includes (suppression.reason as MaterialSyncSuppressionReason)
? label (`suppressions.reasons.${suppression.reason}`)
: suppression.reason} /{' '}
{label ('suppressions.registeredPrefix')}
<time dateTime={suppression.createdAt} lang={locale}>
{localisedDateString (suppression.createdAt, locale)}
</time>
</div> </div>
</article>))} </article>))}
</div> </div>
+94 -80
ファイルの表示
@@ -8,13 +8,14 @@ import ErrorScreen from '@/components/ErrorScreen'
import PostEditForm from '@/components/PostEditForm' import PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import useDialogue from '@/lib/dialogues/useDialogue'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition, import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation' clientScrollBehaviour } from '@/lib/clientAnimation'
@@ -27,8 +28,9 @@ import {
} from '@/lib/settings' } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users' import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils' import { cn, localisedDateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent, ReactNode } from 'react' import type { FC, FormEvent, ReactNode } from 'react'
@@ -65,48 +67,13 @@ const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16 const SIDEBAR_GAP_WIDTH = 16
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = { const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列', threeColumns: 'layouts.threeColumns',
tagsBottom: '2 列 A 型', tagsBottom: 'layouts.tagsBottom',
commentsBottom: '2 列 B 型' } commentsBottom: 'layouts.commentsBottom' }
const TAG_FLOW_LABELS: Record<TagFlow, string> = { const TAG_FLOW_LABELS: Record<TagFlow, string> = {
vertical: '縦並び', vertical: 'flows.vertical',
horizontal: '横並び' } horizontal: 'flows.horizontal' }
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
user ? (user.name || `名もなきニジラー(#${ user.id })`) : '運営'
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null): ReactNode[] =>
[(
<div key={`${ comment.no }-content`} className="w-full">
{comment.deleted
? <span className="text-sm font-bold">削除されました.</span>
: comment.content}
</div>),
(
<div key={`${ comment.no }-user`} className="w-full text-sm text-right">
by {userName (comment.user)}
</div>),
(
<div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right">
{dateString (comment.createdAt)}
</div>),
(
<div
key={`${ comment.no }-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && (
<>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
&thinsp;へのコメント
</>)}
</div>)]
const compareTagName = (a: Tag, b: Tag): number => const compareTagName = (a: Tag, b: Tag): number =>
@@ -176,6 +143,7 @@ const renderReadonlyTagTree = (
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' }) => { { tags, compact, flow = 'vertical' }) => {
const { label } = useUITranslation ()
const horizontalGrouped = tagsByCategory (flattenTags (tags)) const horizontalGrouped = tagsByCategory (flattenTags (tags))
const verticalGrouped = tagsByCategory (tags) const verticalGrouped = tagsByCategory (tags)
@@ -200,7 +168,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return ( return (
<div key={cat}> <div key={cat}>
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400"> <div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
{CATEGORY_NAMES[cat]} {label (`categories.${cat}`)}
</div> </div>
<ul className={cn ('space-y-1', compact && 'text-sm')}> <ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.flatMap (tag => {rows.flatMap (tag =>
@@ -217,6 +185,36 @@ type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => { const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const locale = useLocale () const locale = useLocale ()
const { message, label } = useUITranslation ('theatres')
const userName = (entry: Pick<User, 'id' | 'name'> | null | undefined): ReactNode =>
entry ? (entry.name || label ('history:anonymous', { id: entry.id })) : label ('staff')
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null,
): ReactNode[] => [
(<div key={`${comment.no}-content`} className="w-full">
{comment.deleted
? <span className="text-sm font-bold">{label ('deleted')}</span>
: comment.content}
</div>),
(<div key={`${comment.no}-user`} className="w-full text-sm text-right">
{label ('authorPrefix')}{userName (comment.user)}
</div>),
(<div key={`${comment.no}-createdAt`} className="w-full text-sm text-right">
<LocalisedText language={locale}>
{localisedDateString (comment.createdAt, locale)}
</LocalisedText>
</div>),
(<div key={`${comment.no}-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && (
<>
<PrefetchLink to={`/posts/${programme.post.id}`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
{label ('commentSuffix')}
</>)}
</div>)]
const behaviourSettings = useClientBehaviourSettings () const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal' const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition ( const layoutTransition = clientAnimationTransition (
@@ -644,7 +642,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
const skipVote = theatreInfo.skipVote const skipVote = theatreInfo.skipVote
const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }』` : '上映会場' const theatreTitle = theatre?.name
? message ('namedTitle', { name: theatre.name })
: message ('title')
const postTags = post?.tags ?? [] const postTags = post?.tags ?? []
const programmesAsc = useMemo ( const programmesAsc = useMemo (
() => [...programmes].sort ( () => [...programmes].sort (
@@ -693,7 +693,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const tagPanel = ( const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold">タグ</h2> <h2 className="font-bold">{label ('navigation:tags')}</h2>
{layoutMode === 'tagsBottom' && ( {layoutMode === 'tagsBottom' && (
<div className="hidden gap-2 md:flex"> <div className="hidden gap-2 md:flex">
{(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => ( {(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => (
@@ -703,23 +703,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
size="sm" size="sm"
variant={tagFlow === flow ? 'default' : 'outline'} variant={tagFlow === flow ? 'default' : 'outline'}
onClick={() => changeTagFlow (flow)}> onClick={() => changeTagFlow (flow)}>
{TAG_FLOW_LABELS[flow]} {label (TAG_FLOW_LABELS[flow])}
</Button>))} </Button>))}
</div>)} </div>)}
</div> </div>
{postTags.length === 0 {postTags.length === 0
? <div className="text-sm text-zinc-500">タグはありません。</div> ? <div className="text-sm text-zinc-500">{label ('noTags')}</div>
: <TagList tags={postTags} flow={layoutMode === 'tagsBottom' ? tagFlow : 'vertical'}/>} : <TagList tags={postTags} flow={layoutMode === 'tagsBottom' ? tagFlow : 'vertical'}/>}
</section>) </section>)
const commentsPanel = ( const commentsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">コメント</h2> <h2 className="mb-3 font-bold">{label ('comments')}</h2>
<form onSubmit={handleCommentSubmit}> <form onSubmit={handleCommentSubmit}>
<input <input
className={inputClass ((fieldErrors.content ?? []).length > 0)} className={inputClass ((fieldErrors.content ?? []).length > 0)}
type="text" type="text"
placeholder="ここにコメントを入力" placeholder={message ('commentPlaceholder').text}
lang={message ('commentPlaceholder').language}
value={content} value={content}
onChange={e => setContent (e.target.value)} onChange={e => setContent (e.target.value)}
disabled={sending}/> disabled={sending}/>
@@ -743,18 +744,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
className="absolute left-1 top-1 hidden rounded px-1 text-red-600 className="absolute left-1 top-1 hidden rounded px-1 text-red-600
hover:bg-red-100 group-hover:inline-block dark:text-red-300 hover:bg-red-100 group-hover:inline-block dark:text-red-300
dark:hover:bg-red-950" dark:hover:bg-red-950"
aria-label="コメントを削除" aria-label={message ('deleteComment').text}
lang={message ('deleteComment').language}
onClick={async e => { onClick={async e => {
e.stopPropagation () e.stopPropagation ()
if (!(await dialogue.confirm ({ if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?', title: label ('confirmDelete'),
description: ( description: (
<div className="my-3 w-120 rounded border border-black p-2 <div className="my-3 w-120 rounded border border-black p-2
dark:border-white"> dark:border-white">
{commentBox (comment, commentProgramme)} {commentBox (comment, commentProgramme)}
</div>), </div>),
confirmText: '削除', confirmText: label ('delete'),
cancelText: label ('shared:actions.cancel'),
closeLabel: label ('settings:actions.close'),
variant: 'danger' }))) variant: 'danger' })))
return return
@@ -770,23 +774,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const participantsPanel = ( const participantsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">参加者</h2> <h2 className="mb-3 font-bold">{label ('participants')}</h2>
<div className="space-y-1"> <div className="space-y-1">
{theatreInfo.watchingUsers.map (watchingUser => ( {theatreInfo.watchingUsers.map (watchingUser => (
<div key={watchingUser.id} className="flex justify-between gap-2 text-sm"> <div key={watchingUser.id} className="flex justify-between gap-2 text-sm">
<span>{userName (watchingUser)}</span> <span>{userName (watchingUser)}</span>
{watchingUser.id === user?.id && <span className="text-zinc-500">お前</span>} {watchingUser.id === user?.id && (
<span className="text-zinc-500">{label ('navigation:currentUser')}</span>)}
</div>))} </div>))}
</div> </div>
</section>) </section>)
const historyPanel = ( const historyPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">再生履歴</h2> <h2 className="mb-3 font-bold">{label ('history')}</h2>
<div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72 <div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72
overflow-y-auto"> overflow-y-auto">
{programmes.length === 0 {programmes.length === 0
? <div className="text-sm text-zinc-500">まだ履歴はありません。</div> ? <div className="text-sm text-zinc-500">{label ('emptyHistory')}</div>
: ( : (
programmes.map (programme => ( programmes.map (programme => (
<div <div
@@ -799,7 +804,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{programme.post.title || programme.post.url} {programme.post.title || programme.post.url}
</PrefetchLink> </PrefetchLink>
<div className="text-xs text-zinc-500"> <div className="text-xs text-zinc-500">
{dateString (programme.createdAt)} <LocalisedText language={locale}>
{localisedDateString (programme.createdAt, locale)}
</LocalisedText>
</div> </div>
</div>)))} </div>)))}
</div> </div>
@@ -808,18 +815,18 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const weightsPanel = ( const weightsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold">抽選重み</h2> <h2 className="font-bold">{label ('weights')}</h2>
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}> <Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
更新 {label ('shared:actions.update')}
</Button> </Button>
</div> </div>
<div className="mx-4 grid gap-16 xl:grid-cols-3"> <div className="mx-4 grid gap-16 xl:grid-cols-3">
<div> <div>
<h3 className="mb-2 text-sm font-bold">出にくいタグ</h3> <h3 className="mb-2 text-sm font-bold">{label ('unlikelyTags')}</h3>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
{weights.tagPenalties.length === 0 {weights.tagPenalties.length === 0
? <div className="text-zinc-500">まだ減点はありません。</div> ? <div className="text-zinc-500">{label ('emptyPenalties')}</div>
: ( : (
weights.tagPenalties.slice (0, 12).map (row => ( weights.tagPenalties.slice (0, 12).map (row => (
<div <div
@@ -835,12 +842,12 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</div> </div>
<div> <div>
<h3 className="mb-2 text-sm font-bold">出にくい候補</h3> <h3 className="mb-2 text-sm font-bold">{label ('unlikelyPosts')}</h3>
<WeightRows rows={weights.lightestPosts}/> <WeightRows rows={weights.lightestPosts}/>
</div> </div>
<div> <div>
<h3 className="mb-2 text-sm font-bold">出やすい候補</h3> <h3 className="mb-2 text-sm font-bold">{label ('likelyPosts')}</h3>
<WeightRows rows={weights.heaviestPosts}/> <WeightRows rows={weights.heaviestPosts}/>
</div> </div>
</div> </div>
@@ -854,7 +861,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50"> md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet> <Helmet>
<meta name="robots" content="noindex"/> <meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>} {theatre && <title>{theatreTitle.text} | {SITE_TITLE}</title>}
</Helmet> </Helmet>
<div <div
@@ -882,9 +889,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<div className="flex flex-wrap items-center justify-between gap-3 <div className="flex flex-wrap items-center justify-between gap-3
border-zinc-200 px-4 py-3 dark:border-zinc-800"> border-zinc-200 px-4 py-3 dark:border-zinc-800">
<div> <div>
<h1 className="text-lg font-bold">{theatreTitle}</h1> <h1 className="text-lg font-bold">
<LocalisedText language={theatreTitle.language}>
{theatreTitle.text}
</LocalisedText>
</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400"> <p className="text-sm text-zinc-500 dark:text-zinc-400">
同接 {theatreInfo.watchingUsers.length} 人 {label ('watching', { count: theatreInfo.watchingUsers.length })}
</p> </p>
</div> </div>
@@ -897,7 +908,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
size="sm" size="sm"
variant={layoutMode === mode ? 'default' : 'outline'} variant={layoutMode === mode ? 'default' : 'outline'}
onClick={() => changeLayoutMode (mode)}> onClick={() => changeLayoutMode (mode)}>
{LAYOUT_LABELS[mode]} {label (LAYOUT_LABELS[mode])}
</Button>))} </Button>))}
</div> </div>
@@ -907,7 +918,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
variant={skipVote.voted ? 'secondary' : 'destructive'} variant={skipVote.voted ? 'secondary' : 'destructive'}
disabled={loading || !(post)} disabled={loading || !(post)}
onClick={handleSkipVote}> onClick={handleSkipVote}>
{skipVote.voted ? 'スキップ取消' : 'スキップ'} {skipVote.voted ? label ('cancelSkip') : label ('shared:actions.skip')}
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`} {` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
</Button> </Button>
@@ -924,14 +935,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
onPlaybackChange={syncPlaybackTime} onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : ( onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400"> <div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'} {label (loading ? 'selecting' : 'waiting')}
</div>)} </div>)}
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400"> <div className="text-xs font-bold text-zinc-500 dark:text-zinc-400">
再生中 {label ('playing')}
</div> </div>
{post ? ( {post ? (
<PrefetchLink <PrefetchLink
@@ -939,7 +950,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
className="font-bold hover:underline"> className="font-bold hover:underline">
{post.title || post.url} {post.title || post.url}
</PrefetchLink>) : ( </PrefetchLink>) : (
<span className="text-zinc-500">未選択</span>)} <span className="text-zinc-500">{label ('unselected')}</span>)}
</div> </div>
{(post && canEditContent (user)) && ( {(post && canEditContent (user)) && (
@@ -949,7 +960,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
variant="outline" variant="outline"
disabled={!(post)} disabled={!(post)}
onClick={() => post && setEditingPost (ep => ep ? null : post)}> onClick={() => post && setEditingPost (ep => ep ? null : post)}>
{editingPost ? '閉じる' : '編輯'} {editingPost ? label ('close') : label ('shared:actions.edit')}
</Button>)} </Button>)}
</div> </div>
</section> </section>
@@ -958,14 +969,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4 <section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4
dark:border-amber-800 dark:bg-amber-950/30"> dark:border-amber-800 dark:bg-amber-950/30">
<div className="mb-3"> <div className="mb-3">
<h2 className="font-bold">編輯中の投稿</h2> <h2 className="font-bold">{label ('editingPost')}</h2>
<p className="text-sm text-amber-900 dark:text-amber-100"> <p className="text-sm text-amber-900 dark:text-amber-100">
<PrefetchLink <PrefetchLink
to={`/posts/${ editingPost.id }`} to={`/posts/${ editingPost.id }`}
className="mx-1 font-bold underline"> className="mx-1 font-bold underline">
{editingPost.title || editingPost.url} {editingPost.title || editingPost.url}
</PrefetchLink> </PrefetchLink>
を編輯中…… {label ('editingSuffix')}
</p> </p>
</div> </div>
@@ -1028,10 +1039,12 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => ( const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => {
const { label } = useUITranslation ('theatres')
return (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
{rows.length === 0 {rows.length === 0
? <div className="text-zinc-500">候補はありません。</div> ? <div className="text-zinc-500">{label ('emptyPosts')}</div>
: ( : (
rows.slice (0, 8).map (row => ( rows.slice (0, 8).map (row => (
<div <div
@@ -1043,12 +1056,13 @@ const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = (
className="line-clamp-1 font-bold hover:underline"> className="line-clamp-1 font-bold hover:underline">
{row.post.title || row.post.url} {row.post.title || row.post.url}
</PrefetchLink> </PrefetchLink>
<div className="flex justify-between gap-2 text-xs text-zinc-500"> <div className="flex flex-wrap justify-between gap-2 text-xs text-zinc-500">
<span>penalty {row.penalty}</span> <span>{label ('penalty', { value: row.penalty })}</span>
<span>weight {row.weight.toFixed (3)}</span> <span>{label ('weight', { value: row.weight.toFixed (3) })}</span>
</div> </div>
</div>)))} </div>)))}
</div>) </div>)
}
export default TheatreDetailPage export default TheatreDetailPage