このコミットが含まれているのは:
@@ -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>
|
||||||
|
|||||||
@@ -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>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,8 +2,8 @@
|
|||||||
"categoryLabel": "イソネカ",
|
"categoryLabel": "イソネカ",
|
||||||
"stateLabel": "シㇼキ",
|
"stateLabel": "シㇼキ",
|
||||||
"search": "イフナラ",
|
"search": "イフナラ",
|
||||||
"loading": "タツ゚ㇺ アウㇰ コㇿ アン…",
|
"loading": "タトゥㇺ アウㇰ コㇿ アン…",
|
||||||
"fetchError": "タツ゚ㇺ アウㇰ エアイカㇷ゚",
|
"fetchError": "タトゥㇺ アウㇰ エアイカㇷ゚",
|
||||||
"states": { "active": "アㇱカイ",
|
"states": { "active": "アㇱカイ",
|
||||||
"deprecated": "タネ ソモ アエイワンケ" },
|
"deprecated": "タネ ソモ アエイワンケ" },
|
||||||
"categories": { "deerjikist": "ユㇰ ニチカ ウタㇻ",
|
"categories": { "deerjikist": "ユㇰ ニチカ ウタㇻ",
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"edit": {
|
||||||
|
"validationFailed": "更新はできなかったよ……",
|
||||||
|
"conflictTitle": "競合が発生しました.",
|
||||||
|
"conflictDescription": "ほかの耕作員が先に更新してゐます.",
|
||||||
|
"conflictQuestion": "現在の変更をどう扱ひますか?",
|
||||||
|
"merge": "差分をマージ",
|
||||||
|
"overwrite": "強制上書き"
|
||||||
|
},
|
||||||
|
"_untranslated": ["edit"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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": "その他" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"edit": {
|
||||||
|
"validationFailed": "更新はできなかったよ……",
|
||||||
|
"conflictTitle": "競合が発生しました.",
|
||||||
|
"conflictDescription": "ほかの耕作員が先に更新してゐます.",
|
||||||
|
"conflictQuestion": "現在の変更をどう扱ひますか?",
|
||||||
|
"merge": "差分をマージ",
|
||||||
|
"overwrite": "強制上書き"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}}"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>)}
|
||||||
|
|||||||
@@ -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>)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
 へのコメント
|
|
||||||
</>)}
|
|
||||||
</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
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする