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