このコミットが含まれているのは:
+20
-17
@@ -201,26 +201,29 @@ export const isApiError = <T = unknown> (err: unknown): err is AxiosError<T> =>
|
||||
axios.isAxiosError (err)
|
||||
|
||||
|
||||
// Native downloads let the browser stream to disk without buffering a Blob in app memory.
|
||||
// The server acknowledges a complete file before native download starts.
|
||||
export const apiDownload = async (
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
locale: LocaleCode,
|
||||
onError?: () => void,
|
||||
): Promise<void> => {
|
||||
const token = await csrfToken ()
|
||||
const form = document.createElement ('form')
|
||||
form.method = 'post'
|
||||
form.action = `${API_BASE_URL}${path}?${new URLSearchParams ({ locale })}`
|
||||
form.hidden = true
|
||||
for (const [name, value] of Object.entries ({ ...body, authenticity_token: token }))
|
||||
{
|
||||
const input = document.createElement ('input')
|
||||
input.type = 'hidden'
|
||||
input.name = name
|
||||
input.value = value
|
||||
form.append (input)
|
||||
}
|
||||
document.body.append (form)
|
||||
form.submit ()
|
||||
form.remove ()
|
||||
const { token } = await apiPost<{ token: string }> (`${path}/prepare`, body, { locale })
|
||||
if (typeof token !== 'string' || token.length === 0)
|
||||
throw new Error ('Missing download token')
|
||||
const frame = document.createElement ('iframe')
|
||||
frame.hidden = true
|
||||
frame.addEventListener ('load', () => {
|
||||
const document = frame.contentDocument
|
||||
if (document != null && document.URL !== 'about:blank')
|
||||
{
|
||||
onError?.()
|
||||
frame.remove ()
|
||||
}
|
||||
})
|
||||
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
|
||||
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}`
|
||||
document.body.append (frame)
|
||||
// Attachments do not reliably fire load; keep this isolated from page navigation.
|
||||
window.setTimeout (() => frame.remove (), 10 * 60_000)
|
||||
}
|
||||
|
||||
@@ -32,3 +32,9 @@ export const clientScrollBehaviour = (
|
||||
animationMode: ClientAnimationMode,
|
||||
): ScrollBehavior =>
|
||||
animationMode === 'off' ? 'auto' : 'smooth'
|
||||
|
||||
export const clientMotionTokens = {
|
||||
durationMS: { feedback: 120, popover: 160, collapse: 220, reorder: 200, drop: 180 },
|
||||
easing: [.2, 0, 0, 1] as const,
|
||||
cssEasing: 'cubic-bezier(.2, 0, 0, 1)',
|
||||
distance: 4 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { countsAttributes, countsFieldLabel } from '@/lib/countsDefinitions'
|
||||
import { countsFieldLabel } from '@/lib/countsDefinitions'
|
||||
import { apiDownload, apiGet } from '@/lib/api'
|
||||
|
||||
import type { CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { TagNameSnapshot } from '@/types'
|
||||
|
||||
@@ -135,7 +136,10 @@ const nested: Partial<Record<CountColumn, CountField[]>> = {
|
||||
parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
|
||||
|
||||
|
||||
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
|
||||
export const columnsForCounts = (
|
||||
resource: CountsResource,
|
||||
definition?: CountsDefinition,
|
||||
): CountColumn[] => [
|
||||
...(resource === 'posts' ? ['thumbnail'] : []),
|
||||
'resource_id', 'version_no', 'is_latest',
|
||||
...(resource === 'posts'
|
||||
@@ -143,12 +147,16 @@ export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
|
||||
'original_created_at', 'original_created_from', 'original_created_before']
|
||||
: (resource === 'tags'
|
||||
? ['name', 'names', 'category', 'aliases', 'parents', 'deprecated']
|
||||
: Object.keys (countsAttributes[resource]))),
|
||||
: (definition?.fields.filter (field => ![
|
||||
'resource_id', 'version_no', 'event_type', 'user', 'created_at']
|
||||
.includes (field.key)).map (field => field.key) ?? []))),
|
||||
'event_type', 'user', 'created_at']
|
||||
|
||||
|
||||
export const fieldsForCounts = (resource: CountsResource): CountField[] =>
|
||||
columnsForCounts (resource).map (key => ({
|
||||
export const fieldsForCounts = (
|
||||
resource: CountsResource,
|
||||
definition?: CountsDefinition,
|
||||
): CountField[] => columnsForCounts (resource, definition).map (key => ({
|
||||
key,
|
||||
label: (key === 'parents' && resource === 'tags') ? 'tags:parents'
|
||||
: (countColumns[key] ?? countsFieldLabel (key)),
|
||||
@@ -196,12 +204,13 @@ export const reorderCountFields = (
|
||||
export const selectedCountColumns = (
|
||||
resource: CountsResource,
|
||||
params: URLSearchParams,
|
||||
definition?: CountsDefinition,
|
||||
): CountColumn[] => {
|
||||
const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
|
||||
columnsForCounts (resource).filter (key => ![
|
||||
columnsForCounts (resource, definition).filter (key => ![
|
||||
'thumbnail', 'original_created_from', 'original_created_before']
|
||||
.includes (key))
|
||||
return orderedCountFields (fieldsForCounts (resource), params)
|
||||
return orderedCountFields (fieldsForCounts (resource, definition), params)
|
||||
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
||||
}
|
||||
|
||||
@@ -281,7 +290,7 @@ export const normaliseCountsParams = (
|
||||
params.set ('through', params.get ('before')!)
|
||||
for (const key of ['basis', 'axis', 'latest', 'grain', 'before', 'summary_columns'])
|
||||
params.delete (key)
|
||||
if (params.has ('columns'))
|
||||
if (params.has ('columns') && ['posts', 'tags'].includes (resource))
|
||||
{
|
||||
const selected = new Set (params.get ('columns')!.split (','))
|
||||
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
||||
@@ -321,10 +330,11 @@ export const exportCounts = (
|
||||
columns: string[],
|
||||
headers: string[],
|
||||
labels: Record<string, string>,
|
||||
onError?: () => void,
|
||||
): Promise<void> => apiDownload ('/counts/export', {
|
||||
...Object.fromEntries (params), resource, format,
|
||||
columns: columns.join (','), headers: JSON.stringify (headers),
|
||||
labels: JSON.stringify (labels) }, locale)
|
||||
labels: JSON.stringify (labels) }, locale, onError)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { CountField, CountsResource } from '@/lib/counts'
|
||||
|
||||
export type CountsAxis = { field: string; grain?: string }
|
||||
export type CountsCondition = {
|
||||
field: string; operator: string; value?: string; end?: string; blank?: boolean }
|
||||
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json'
|
||||
export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum' | 'json' | 'boolean'
|
||||
|
||||
export const countsResources: CountsResource[] = [
|
||||
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
|
||||
export const countsGrains = ['year', 'quarter', 'month', 'week', 'day', 'hour']
|
||||
export const countsOperators: Record<CountsFieldType, string[]> = {
|
||||
string: ['eq', 'ne', 'like'], enum: ['eq', 'ne'], json: [],
|
||||
integer: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'],
|
||||
datetime: ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'range'] }
|
||||
export type CountsFieldMetadata = {
|
||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[] }
|
||||
export type CountsDefinition = {
|
||||
fields: CountsFieldMetadata[]
|
||||
axes: { latest: string[]; history: string[] }
|
||||
grains: string[] }
|
||||
|
||||
export const countsAttributes: Record<CountsResource, Record<string, CountsFieldType>> = {
|
||||
posts: { title: 'string', url: 'string', thumbnail_base: 'string', tags_json: 'json',
|
||||
parent_post_ids: 'string', video_ms: 'integer', original_created_from: 'datetime',
|
||||
original_created_before: 'datetime' },
|
||||
tags: { name: 'string', names: 'json', category: 'enum', aliases: 'string',
|
||||
parent_tag_ids: 'string', deprecated_at: 'datetime' },
|
||||
nico_tags: { name: 'string', linked_tags: 'string' },
|
||||
tag_descriptions: { language_code: 'enum', body: 'string', source_language_code: 'enum',
|
||||
source_version_no: 'integer' },
|
||||
tag_groups: { name: 'string', description: 'string', members: 'json', placements: 'json' },
|
||||
materials: { url: 'string', parent_id: 'integer', tag_id: 'integer', tag_name: 'string',
|
||||
tag_category: 'enum', discarded_at: 'datetime', updated_at: 'datetime',
|
||||
updated_by_user_id: 'integer', export_paths_json: 'json', file_blob_id: 'integer',
|
||||
file_filename: 'string', file_content_type: 'string', file_byte_size: 'integer',
|
||||
file_checksum: 'string', file_sha256: 'string', source_kind: 'enum', source_uri: 'string',
|
||||
source_path: 'string', source_file_id: 'string', normalized_source_key: 'string' } }
|
||||
export const CountsDefinitionContext = createContext<CountsDefinition | null> (null)
|
||||
|
||||
|
||||
export const useCountsDefinition = (): CountsDefinition => {
|
||||
const definition = useContext (CountsDefinitionContext)
|
||||
if (definition == null) throw new Error ('Missing Counts definition')
|
||||
return definition
|
||||
}
|
||||
|
||||
|
||||
export const fetchCountsDefinition = (resource: CountsResource): Promise<CountsDefinition> =>
|
||||
apiGet (`/counts/definitions/${encodeURIComponent (resource)}`)
|
||||
|
||||
|
||||
export const countsFilterFields = (
|
||||
resource: CountsResource,
|
||||
): Record<string, CountsFieldType> => ({
|
||||
resource_id: 'integer' as const, version_no: 'integer' as const,
|
||||
event_type: 'enum' as const, user: 'integer' as const, created_at: 'datetime' as const,
|
||||
...countsAttributes[resource] })
|
||||
definition: CountsDefinition,
|
||||
): Record<string, CountsFieldType> => Object.fromEntries (
|
||||
definition.fields.map (field => [field.key, field.type]))
|
||||
|
||||
|
||||
export const countsFieldLabel = (key: string): string => {
|
||||
@@ -52,12 +51,11 @@ export const countsFieldLabel = (key: string): string => {
|
||||
}
|
||||
|
||||
|
||||
export const countsAxisFields = (resource: CountsResource, history: boolean): CountField[] => [
|
||||
...Object.entries (countsFilterFields (resource))
|
||||
.filter (([, type]) => type !== 'json')
|
||||
.map (([key]) => ({ key, label: countsFieldLabel (key) })),
|
||||
...(resource === 'posts' ? [{ key: 'tags', label: 'columns.tags' }] : []),
|
||||
...(history ? [{ key: 'changed_field', label: 'changedField' }] : [])]
|
||||
export const countsAxisFields = (
|
||||
definition: CountsDefinition,
|
||||
history: boolean,
|
||||
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
||||
.map (key => ({ key, label: countsFieldLabel (key) }))
|
||||
|
||||
|
||||
export const readCountsJSON = <T,> (params: URLSearchParams, key: string, fallback: T): T => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/ut
|
||||
|
||||
import type { CountColumn, CountField, CountRecord,
|
||||
CountsResource, CountsVersion } from '@/lib/counts'
|
||||
import type { CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
|
||||
@@ -245,6 +246,7 @@ export const countsImageURL = (value: string | null | undefined): string | null
|
||||
export const countsExportLabels = (
|
||||
resource: CountsResource,
|
||||
locale: LocaleCode,
|
||||
definition?: CountsDefinition,
|
||||
): Record<string, string> => {
|
||||
const aliases: Record<string, string> = { versionNo: 'version_no',
|
||||
languageCode: 'language', scriptCode: 'script', autoGenerated: 'auto_generated' }
|
||||
@@ -257,7 +259,7 @@ export const countsExportLabels = (
|
||||
if (field.children) visit (field.children)
|
||||
}
|
||||
}
|
||||
visit (fieldsForCounts (resource))
|
||||
visit (fieldsForCounts (resource, definition))
|
||||
for (const key of ['internal', 'external'])
|
||||
labels[`kind:${key}`] = translateUIMessage (`kinds.${key}`, 'counts', locale).text
|
||||
for (const key of ['deerjikist', 'meme', 'character', 'general', 'material', 'meta'])
|
||||
|
||||
@@ -163,5 +163,6 @@ export const theatresKeys = {
|
||||
['theatres', locale, id, 'programmes'] as const }
|
||||
|
||||
export const countsKeys = {
|
||||
definition: (resource: string) => ['counts', 'definition', resource] as const,
|
||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||
['counts', resource, locale, params] as const }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useReducedMotion } from 'framer-motion'
|
||||
|
||||
import { clientAnimationTransition, clientMotionTokens } from '@/lib/clientAnimation'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
|
||||
|
||||
export const useClientMotion = () => {
|
||||
const settings = useClientBehaviourSettings ()
|
||||
const osReduced = useReducedMotion ()
|
||||
const mode = osReduced ? 'reduced' : settings.animation ?? 'normal'
|
||||
const reduced = mode !== 'normal'
|
||||
const duration = (kind: keyof typeof clientMotionTokens.durationMS) =>
|
||||
reduced ? 0 : clientMotionTokens.durationMS[kind]
|
||||
const transition = (kind: keyof typeof clientMotionTokens.durationMS) =>
|
||||
clientAnimationTransition (mode, {
|
||||
normal: { duration: duration (kind) / 1000, ease: clientMotionTokens.easing },
|
||||
reduced: { duration: 0 } })
|
||||
return { reduced, duration, transition, easing: clientMotionTokens.cssEasing }
|
||||
}
|
||||
新しいイシューから参照
ユーザーをブロックする