コミットを比較
8
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
a636472ec3 | ||
|
|
be8c3c75c0 | ||
|
|
af9bbe67a4 | ||
|
|
5ab4381b4e | ||
|
|
103bc75a33 | ||
|
|
9d10e6830c | ||
|
|
0d369618f4 | ||
|
|
2b7f52978f |
@@ -6,14 +6,8 @@ class CountsCandidates
|
|||||||
|
|
||||||
def initialize(params)
|
def initialize(params)
|
||||||
QueryState.validate!(params, KEYS)
|
QueryState.validate!(params, KEYS)
|
||||||
definition = CountsResourceDefinition.new(params.fetch('resource'))
|
raise ArgumentError unless params.fetch('resource') == 'posts'
|
||||||
fields = ['resource_id', 'user']
|
raise ArgumentError unless ['tags', 'tags_json'].include?(params.fetch('field'))
|
||||||
fields += ['tags', 'tags_json', 'tags.id'] if definition.resource == 'posts'
|
|
||||||
fields << 'members' if definition.resource == 'tag_groups'
|
|
||||||
if definition.resource == 'materials'
|
|
||||||
fields += ['updated_by_user_id', 'file_blob_id', 'source_file_id', 'drive_file_id']
|
|
||||||
end
|
|
||||||
raise ArgumentError unless fields.include?(params.fetch('field'))
|
|
||||||
|
|
||||||
@params = params
|
@params = params
|
||||||
@page = Integer(params.fetch('page', '1'), 10)
|
@page = Integer(params.fetch('page', '1'), 10)
|
||||||
@@ -24,27 +18,25 @@ class CountsCandidates
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
references = ['tags', 'tags_json', 'tags.id', 'members']
|
|
||||||
return resource_candidates unless references.include?(@params['field'])
|
|
||||||
|
|
||||||
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
|
options = @params.slice('locale', 'at', 'execution_at', 'timezone')
|
||||||
.merge('definition' => 'latest', 'resource' => 'tags')
|
.merge('definition' => 'latest', 'resource' => 'tags')
|
||||||
internal = CountsPopulation.new(options)
|
internal = CountsPopulation.new(options)
|
||||||
groups_flg = @params['field'] == 'members'
|
external = CountsPopulation.new(options.merge('resource' => 'nico_tags',
|
||||||
external = CountsPopulation.new(options.merge(
|
|
||||||
'resource' => groups_flg ? 'tag_groups' : 'nico_tags',
|
|
||||||
'execution_at' => internal.at.iso8601(6)))
|
'execution_at' => internal.at.iso8601(6)))
|
||||||
connection = TagVersion.connection
|
connection = TagVersion.connection
|
||||||
sources = [[internal, 'internal', 'v.category', 'v.names'],
|
sources = [[internal, 'internal', 'v.category', 'v.names'],
|
||||||
[external, groups_flg ? 'group' : 'external', 'NULL', 'JSON_OBJECT()']]
|
[external, 'external', 'NULL', 'JSON_OBJECT()']]
|
||||||
sources = sources.map do |population, kind, category, names|
|
sources = sources.map do |population, kind, category, names|
|
||||||
scope = population.scope.except(:select)
|
scope = population.scope.except(:select)
|
||||||
identity = "v.#{ population.definition.keys.first }"
|
if @params['query'].present?
|
||||||
scope = search(scope, identity, 'v.name', names)
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
|
||||||
value = ['tags.id', 'members'].include?(@params['field']) ?
|
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
|
||||||
"CAST(#{ identity } AS CHAR)" : "CONCAT('#{ kind }:', #{ identity })"
|
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
|
||||||
|
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
|
||||||
|
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
|
||||||
|
end
|
||||||
scope.select(Arel.sql(
|
scope.select(Arel.sql(
|
||||||
"#{ value } AS value, #{ identity } AS id, " +
|
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
|
||||||
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
|
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
|
||||||
.to_sql
|
.to_sql
|
||||||
end
|
end
|
||||||
@@ -63,63 +55,4 @@ class CountsCandidates
|
|||||||
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
||||||
execution_at: internal.at.iso8601(6) }
|
execution_at: internal.at.iso8601(6) }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def search(scope, identity, name, names)
|
|
||||||
return scope if @params['query'].blank?
|
|
||||||
|
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
|
|
||||||
scope.where("(#{ name } LIKE ? OR CAST(#{ identity } AS CHAR) = ? OR EXISTS (" +
|
|
||||||
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
|
|
||||||
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
|
|
||||||
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
|
|
||||||
end
|
|
||||||
|
|
||||||
def resource_candidates
|
|
||||||
options = @params.slice('at', 'execution_at', 'timezone')
|
|
||||||
.merge('resource' => @params.fetch('resource'), 'definition' => 'latest')
|
|
||||||
actor_flg = ['user', 'updated_by_user_id'].include?(@params['field'])
|
|
||||||
options['definition'] = 'history' if actor_flg
|
|
||||||
population = CountsPopulation.new(options)
|
|
||||||
scope = population.scope.except(:select)
|
|
||||||
if actor_flg
|
|
||||||
point = population.timestamp(@params['at']) || population.at
|
|
||||||
raise ArgumentError if point > population.at
|
|
||||||
|
|
||||||
column = @params['field'] == 'user' ? 'created_by_user_id' : 'updated_by_user_id'
|
|
||||||
identities = scope.where('v.created_at <= ?', point).select("v.#{ column }")
|
|
||||||
scope = User.where(id: identities)
|
|
||||||
identity = 'users.id'
|
|
||||||
name = 'users.name'
|
|
||||||
names = 'JSON_OBJECT()'
|
|
||||||
else
|
|
||||||
definition = population.definition
|
|
||||||
column = @params['field'] == 'resource_id' ? definition.keys.first : @params['field']
|
|
||||||
if column == 'drive_file_id'
|
|
||||||
column = 'source_file_id'
|
|
||||||
scope = scope.where('v.source_kind' => 'google_drive_file')
|
|
||||||
end
|
|
||||||
identity = "v.#{ column }"
|
|
||||||
label = ['title', 'name', 'url'].find { definition.fields.key?(_1) }
|
|
||||||
name = label ? "v.#{ label }" : 'NULL'
|
|
||||||
names = definition.resource == 'tags' ? 'v.names' : 'JSON_OBJECT()'
|
|
||||||
if ['file_blob_id', 'source_file_id', 'drive_file_id'].include?(@params['field'])
|
|
||||||
scope = scope.where.not("v.#{ column }" => nil).group(Arel.sql(identity))
|
|
||||||
name = 'v.file_filename'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
scope = search(scope, identity, name, names)
|
|
||||||
title = ['file_blob_id', 'source_file_id', 'drive_file_id'].include?(@params['field']) ?
|
|
||||||
"MIN(#{ name })" : name
|
|
||||||
query = scope.optimizer_hints('MAX_EXECUTION_TIME(10000)').distinct
|
|
||||||
.select(Arel.sql("#{ identity } AS id, #{ title } AS name, #{ names } AS names"))
|
|
||||||
.order(Arel.sql(identity)).limit(LIMIT + 1).offset((@page - 1) * LIMIT)
|
|
||||||
rows = scope.klass.connection.select_all(query.to_sql).to_a.map do |values|
|
|
||||||
values['names'] = JSON.parse(values['names']) if values['names'].is_a?(String)
|
|
||||||
values.merge('value' => values.fetch('id').to_s)
|
|
||||||
end
|
|
||||||
{ rows: rows.first(LIMIT), page: @page, more: rows.length > LIMIT,
|
|
||||||
execution_at: population.at.iso8601(6) }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
バイナリファイルは表示されません。
@@ -0,0 +1,11 @@
|
|||||||
|
Noto Sans Mongolian Regular, unmodified TrueType font.
|
||||||
|
|
||||||
|
Font copyright: Copyright 2018 Google Inc. All Rights Reserved.
|
||||||
|
|
||||||
|
- Source: https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSansMongolian
|
||||||
|
- Font: https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMongolian/NotoSansMongolian-Regular.ttf
|
||||||
|
- License: SIL Open Font License 1.1; see the accompanying LICENSE.
|
||||||
|
- Retrieved: 2026-11-08.
|
||||||
|
|
||||||
|
Used by the Mongolian UI at `/mn-Mong`.
|
||||||
|
The font and license are served together from this directory.
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { createServer } from 'vite'
|
||||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { generateHelpAssets } from './help-assets'
|
import { generateHelpAssets, helpAssets } from './help-assets'
|
||||||
|
|
||||||
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
|
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
|
||||||
|
|
||||||
@@ -109,4 +110,22 @@ describe ('Build-time Help assets', () => {
|
|||||||
it ('rejects an empty Help checkout', async () => {
|
it ('rejects an empty Help checkout', async () => {
|
||||||
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
|
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('defers missing Help errors until a Help module is requested', async () => {
|
||||||
|
const server = await createServer ({
|
||||||
|
configFile: false,
|
||||||
|
root,
|
||||||
|
plugins: [helpAssets ()],
|
||||||
|
server: { middlewareMode: true, watch: null } })
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await server.pluginContainer.buildStart ({})
|
||||||
|
await expect (server.pluginContainer.load ('\0virtual:help/articles'))
|
||||||
|
.rejects.toThrow ('ENOENT')
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await server.close ()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -159,10 +159,9 @@ export const helpAssets = (): Plugin => {
|
|||||||
configResolved (config) {
|
configResolved (config) {
|
||||||
state.root = path.resolve (config.root, 'help')
|
state.root = path.resolve (config.root, 'help')
|
||||||
},
|
},
|
||||||
async buildStart () {
|
buildStart () {
|
||||||
state.data = generateHelpAssets (state.root)
|
state.data = undefined
|
||||||
const generated = await data ()
|
this.addWatchFile (state.root)
|
||||||
generated.files.forEach (file => this.addWatchFile (file))
|
|
||||||
},
|
},
|
||||||
resolveId (id) {
|
resolveId (id) {
|
||||||
return id.startsWith (prefix) ? `\0${ id }` : null
|
return id.startsWith (prefix) ? `\0${ id }` : null
|
||||||
@@ -170,7 +169,9 @@ export const helpAssets = (): Plugin => {
|
|||||||
async load (id) {
|
async load (id) {
|
||||||
if (!(id.startsWith (`\0${ prefix }`)))
|
if (!(id.startsWith (`\0${ prefix }`)))
|
||||||
return null
|
return null
|
||||||
const source = (await data ()).modules.get (id.slice (1))
|
const generated = await data ()
|
||||||
|
generated.files.forEach (file => this.addWatchFile (file))
|
||||||
|
const source = generated.modules.get (id.slice (1))
|
||||||
if (source == null)
|
if (source == null)
|
||||||
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
|
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
|
||||||
return source
|
return source
|
||||||
|
|||||||
+30
-19
@@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster'
|
|||||||
import { isApiError } from '@/lib/api'
|
import { isApiError } from '@/lib/api'
|
||||||
import { initialiseUserSession } from '@/lib/users'
|
import { initialiseUserSession } from '@/lib/users'
|
||||||
import { prepareHelpPage } from '@/lib/help'
|
import { prepareHelpPage } from '@/lib/help'
|
||||||
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath,
|
||||||
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||||
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
import { isCountsPath } from '@/lib/routeLocales'
|
import { isCountsPath } from '@/lib/routeLocales'
|
||||||
import { switchPostFilterLocale } from '@/lib/posts'
|
import { switchPostFilterLocale } from '@/lib/posts'
|
||||||
import { switchWikiLocale } from '@/lib/wiki'
|
import { switchWikiLocale } from '@/lib/wiki'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale, useUILocale, useVerticalPreview } from '@/lib/useLocale'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
fetchUserThemeSlots,
|
fetchUserThemeSlots,
|
||||||
@@ -46,7 +46,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
|||||||
import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||||
import type { RouteMatch } from 'react-router-dom'
|
import type { RouteMatch } from 'react-router-dom'
|
||||||
|
|
||||||
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
|
import type { AvailableLocale, LocaleCode, UILocaleCode } from '@/lib/locales'
|
||||||
import type { PageRoute } from '@/lib/routeLocales'
|
import type { PageRoute } from '@/lib/routeLocales'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
@@ -267,15 +267,21 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
const client = useQueryClient ()
|
const client = useQueryClient ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const verticalPreview = useVerticalPreview ()
|
||||||
const route = resolvePageRoute (location.pathname)
|
const route = resolvePageRoute (location.pathname)
|
||||||
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
|
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
|
||||||
const pagePath = unlocalisedPath (location.pathname)
|
const pagePath = unlocalisedPath (location.pathname)
|
||||||
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
|
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
|
||||||
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
|
const switchLocale = async (target: UILocaleCode): Promise<string> => {
|
||||||
|
const targetLocale = target === 'mn-Mong' ? 'ja' : target
|
||||||
|
if (targetLocale === locale)
|
||||||
|
return switchLocalePath (location, target)
|
||||||
const resolve = route?.route.handle?.switchLocale
|
const resolve = route?.route.handle?.switchLocale
|
||||||
return (resolve != null && route != null)
|
const destination =
|
||||||
? resolve ({ location, params: route.params, locale, targetLocale })
|
(resolve != null && route != null)
|
||||||
|
? await resolve ({ location, params: route.params, locale, targetLocale })
|
||||||
: switchLocalePath (location, targetLocale)
|
: switchLocalePath (location, targetLocale)
|
||||||
|
return target === 'mn-Mong' ? `/mn-Mong${unlocalisedPath (destination)}` : destination
|
||||||
}
|
}
|
||||||
const [user, setUserState] = useState<User | null> (
|
const [user, setUserState] = useState<User | null> (
|
||||||
() => client.getQueryData<User> (sessionKeys.current) ?? null)
|
() => client.getQueryData<User> (sessionKeys.current) ?? null)
|
||||||
@@ -393,7 +399,8 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
|
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
data-vertical-preview={verticalPreview || undefined}
|
||||||
|
className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||||
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
||||||
<RouteTransitionWrapper
|
<RouteTransitionWrapper
|
||||||
user={user}
|
user={user}
|
||||||
@@ -411,7 +418,9 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
|
|
||||||
const LocaleRoute: FC = () => {
|
const LocaleRoute: FC = () => {
|
||||||
const { locale } = useParams ()
|
const { locale } = useParams ()
|
||||||
|
const uiLocale = useUILocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
const verticalPreview = isVerticalPreviewPath (location.pathname)
|
||||||
const { i18n } = useTranslation ()
|
const { i18n } = useTranslation ()
|
||||||
const { data, isError } = useQuery ({
|
const { data, isError } = useQuery ({
|
||||||
queryKey: localesKeys.index,
|
queryKey: localesKeys.index,
|
||||||
@@ -421,29 +430,30 @@ const LocaleRoute: FC = () => {
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true })
|
refetchOnReconnect: true })
|
||||||
|
const contentLocale = verticalPreview ? 'ja' : locale
|
||||||
const activeLocale =
|
const activeLocale =
|
||||||
(isLocaleCode (locale)
|
(isLocaleCode (contentLocale)
|
||||||
&& data?.locales.some (entry => entry.code === locale))
|
&& data?.locales.some (entry => entry.code === contentLocale))
|
||||||
? locale
|
? contentLocale
|
||||||
: null
|
: null
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
if (activeLocale != null)
|
if (activeLocale != null)
|
||||||
document.documentElement.lang = activeLocale
|
document.documentElement.lang = uiLocale
|
||||||
if (activeLocale != null && i18n.language !== activeLocale)
|
if (activeLocale != null && i18n.language !== uiLocale)
|
||||||
void i18n.changeLanguage (activeLocale)
|
void i18n.changeLanguage (uiLocale)
|
||||||
}, [activeLocale, i18n])
|
}, [activeLocale, uiLocale, i18n])
|
||||||
|
|
||||||
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
|
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
|
||||||
if (!(isLocaleCode (locale)) && redirect == null)
|
if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null)
|
||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
if (isError && data == null)
|
if (isError && data == null)
|
||||||
return <ServiceUnavailable/>
|
return <ServiceUnavailable/>
|
||||||
if (data == null)
|
if (data == null)
|
||||||
return null
|
return null
|
||||||
if (isLocaleCode (locale))
|
if (isLocaleCode (locale) || verticalPreview)
|
||||||
{
|
{
|
||||||
return data.locales.some (entry => entry.code === locale)
|
return data.locales.some (entry => entry.code === contentLocale)
|
||||||
? <RoutedApp locales={data.locales}/>
|
? <RoutedApp locales={data.locales}/>
|
||||||
: <NotFound/>
|
: <NotFound/>
|
||||||
}
|
}
|
||||||
@@ -455,7 +465,8 @@ const LocaleRoute: FC = () => {
|
|||||||
const LocaleRedirect = ({ to }: { to: string }) => {
|
const LocaleRedirect = ({ to }: { to: string }) => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
|
return <Navigate to={localiseTo (
|
||||||
|
{ ...location, pathname: to }, locale, location.pathname)} replace/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
@@ -185,12 +184,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
value={parentPostIds}
|
value={parentPostIds}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
errors={fieldErrors.parentPostIds}
|
errors={fieldErrors.parentPostIds}
|
||||||
onChange={setParentPostIds}
|
onChange={setParentPostIds}/>
|
||||||
after={<ResourcePickerButton
|
|
||||||
source={{ resource: 'posts', field: 'resource_id' }}
|
|
||||||
values={parentPostIds.split (/\s+/).filter (Boolean)}
|
|
||||||
maximum={Number.MAX_SAFE_INTEGER} disabled={disabled}
|
|
||||||
onSelect={values => setParentPostIds (values.join (' '))}/>}/>
|
|
||||||
|
|
||||||
<PostTagsField
|
<PostTagsField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
const cardRef = useRef<HTMLDivElement> (null)
|
const cardRef = useRef<HTMLDivElement> (null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-6 p-4">
|
<div className="post-list flex flex-wrap gap-6 p-4">
|
||||||
{posts.map ((post, i) => {
|
{posts.map ((post, i) => {
|
||||||
const sharedId = `page-${ post.id }`
|
const sharedId = `page-${ post.id }`
|
||||||
const layoutId = sharedId
|
const layoutId = sharedId
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { forwardRef, useMemo } from 'react'
|
import { forwardRef, useMemo } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { createPath } from 'react-router-dom'
|
import { createPath, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { localiseTo } from '@/lib/locales'
|
import { isVerticalPreviewPath, localeFromPath,
|
||||||
|
localiseTo, unlocalisedPath } from '@/lib/locales'
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||||
import { prefetchForURL } from '@/lib/prefetchers'
|
import { prefetchForURL } from '@/lib/prefetchers'
|
||||||
@@ -38,15 +39,18 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
|
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const { pathname } = useLocation ()
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||||
const path = useMemo (
|
const path = useMemo (
|
||||||
() => {
|
() => {
|
||||||
const localised = localiseTo (to, locale)
|
const localised = localiseTo (to, locale, pathname)
|
||||||
return typeof localised === 'string' ? localised : createPath (localised)
|
const destination = typeof localised === 'string' ? localised : createPath (localised)
|
||||||
|
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
|
||||||
|
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
|
||||||
},
|
},
|
||||||
[to, locale],
|
[to, locale, pathname],
|
||||||
)
|
)
|
||||||
const url = useMemo (() => {
|
const url = useMemo (() => {
|
||||||
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
const spanClass = 'tag-link-colour'
|
const spanClass = 'tag-link-colour'
|
||||||
const linkClass = 'tag-link-colour tag-link-hover-colour'
|
const linkClass = 'tag-link-colour tag-link-hover-colour'
|
||||||
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
|
const textClass = cn (
|
||||||
|
'group [min-inline-size:0] [max-inline-size:100%]',
|
||||||
|
'overflow-hidden align-bottom')
|
||||||
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
||||||
const countClass = 'shrink-0 self-end md:self-auto'
|
const countClass = 'shrink-0 self-end md:self-auto'
|
||||||
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
|
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
|
||||||
@@ -89,7 +91,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn (
|
className={cn (
|
||||||
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
|
'inline-flex [min-inline-size:0] [max-inline-size:100%] flex-nowrap',
|
||||||
|
'items-stretch align-baseline',
|
||||||
'gap-x-1 md:items-baseline')}>
|
'gap-x-1 md:items-baseline')}>
|
||||||
{(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && (
|
{(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && (
|
||||||
<span className={markerWrapClass}>
|
<span className={markerWrapClass}>
|
||||||
@@ -153,8 +156,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
</span>)}
|
</span>)}
|
||||||
{nestLevel > 0 && (
|
{nestLevel > 0 && (
|
||||||
<span
|
<span
|
||||||
className="ml-1 mr-1 shrink-0"
|
className="ms-1 me-1 shrink-0"
|
||||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
style={{ paddingInlineStart: `${ (nestLevel - 1) }rem` }}>
|
||||||
↳
|
↳
|
||||||
</span>)}
|
</span>)}
|
||||||
{matchedAlias != null && (
|
{matchedAlias != null && (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
@@ -18,6 +18,7 @@ import type { Tag } from '@/types'
|
|||||||
const TagSearch: FC = () => {
|
const TagSearch: FC = () => {
|
||||||
const { message } = useUITranslation ('shared')
|
const { message } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const verticalPreview = useVerticalPreview ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const compositionFlgRef = useRef (false)
|
const compositionFlgRef = useRef (false)
|
||||||
@@ -105,13 +106,13 @@ const TagSearch: FC = () => {
|
|||||||
|
|
||||||
switch (ev.key)
|
switch (ev.key)
|
||||||
{
|
{
|
||||||
case 'ArrowDown':
|
case verticalPreview ? 'ArrowRight' : 'ArrowDown':
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
||||||
setSuggestionsVsbl (true)
|
setSuggestionsVsbl (true)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'ArrowUp':
|
case verticalPreview ? 'ArrowLeft' : 'ArrowUp':
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
setActiveIndex (i => Math.max (i - 1, -1))
|
setActiveIndex (i => Math.max (i - 1, -1))
|
||||||
setSuggestionsVsbl (true)
|
setSuggestionsVsbl (true)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="absolute left-0 right-0 z-50 w-full
|
<ul className="tag-search-suggestions absolute left-0 right-0 z-50 w-full
|
||||||
bg-gray-100 dark:bg-gray-800 border dark:border-gray-600
|
bg-gray-100 dark:bg-gray-800 border dark:border-gray-600
|
||||||
rounded shadow">
|
rounded shadow">
|
||||||
{suggestions.map ((tag, i) => (
|
{suggestions.map ((tag, i) => (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
|
|||||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate, useUILocale, useVerticalPreview } from '@/lib/useLocale'
|
||||||
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
||||||
import Separator from '@/components/MenuSeparator'
|
import Separator from '@/components/MenuSeparator'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
@@ -12,9 +12,9 @@ import TopNavUser from '@/components/TopNavUser'
|
|||||||
import TranslatedText from '@/components/TranslatedText'
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||||
import { isLocaleCode,
|
import { isUILocaleCode,
|
||||||
localeFromPath,
|
|
||||||
switchLocalePath,
|
switchLocalePath,
|
||||||
|
uiLocaleFromPath,
|
||||||
unlocalisedPath } from '@/lib/locales'
|
unlocalisedPath } from '@/lib/locales'
|
||||||
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
|
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
@@ -28,13 +28,13 @@ import { fetchWikiPage } from '@/lib/wiki'
|
|||||||
|
|
||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
|
||||||
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
|
import type { AvailableLocale, UILocaleCode } from '@/lib/locales'
|
||||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User | null
|
user: User | null
|
||||||
locales: AvailableLocale[]
|
locales: AvailableLocale[]
|
||||||
resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
|
resolveLocaleSwitch?: (locale: UILocaleCode) => Promise<string> }
|
||||||
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export const menuOutline = (
|
|||||||
pathName: string },
|
pathName: string },
|
||||||
): Menu => {
|
): Menu => {
|
||||||
const pathName = unlocalisedPath (fullPathname)
|
const pathName = unlocalisedPath (fullPathname)
|
||||||
const locale = localeFromPath (fullPathname) ?? 'ja'
|
const locale = uiLocaleFromPath (fullPathname)
|
||||||
|
|
||||||
const message = (
|
const message = (
|
||||||
key: Parameters<typeof uiMessage>[1],
|
key: Parameters<typeof uiMessage>[1],
|
||||||
@@ -137,7 +137,10 @@ export const menuOutline = (
|
|||||||
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||||
const { message, label } = useUITranslation ('shared')
|
const { message, label } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const localeName = locales.find (entry => entry.code === locale)?.name
|
const uiLocale = useUILocale ()
|
||||||
|
const verticalPreview = useVerticalPreview ()
|
||||||
|
const uiLocales = locales.filter (entry => isUILocaleCode (entry.code))
|
||||||
|
const localeName = uiLocales.find (entry => entry.code === uiLocale)?.name
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const currentLocation = useRef (location)
|
const currentLocation = useRef (location)
|
||||||
@@ -180,11 +183,12 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
let node: HTMLElement | null = el
|
let node: HTMLElement | null = el
|
||||||
while (node != null && node !== nav)
|
while (node != null && node !== nav)
|
||||||
{
|
{
|
||||||
left += node.offsetLeft
|
left += verticalPreview ? node.offsetTop : node.offsetLeft
|
||||||
const parent: Element | null = node.offsetParent
|
const parent: Element | null = node.offsetParent
|
||||||
node = parent instanceof HTMLElement ? parent : null
|
node = parent instanceof HTMLElement ? parent : null
|
||||||
}
|
}
|
||||||
const width = node === nav ? el.offsetWidth : 0
|
const width = node === nav
|
||||||
|
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
|
||||||
const visible = width > 0
|
const visible = width > 0
|
||||||
setHL (current =>
|
setHL (current =>
|
||||||
(current.left === left && current.width === width && current.visible === visible)
|
(current.left === left && current.width === width && current.visible === visible)
|
||||||
@@ -202,6 +206,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||||
const [submenuHeight, setSubmenuHeight] = useState (0)
|
const [submenuHeight, setSubmenuHeight] = useState (0)
|
||||||
|
const [submenuWidth, setSubmenuWidth] = useState (0)
|
||||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||||
|
|
||||||
const wikiIdStr = String (wikiId ?? '')
|
const wikiIdStr = String (wikiId ?? '')
|
||||||
@@ -255,8 +260,22 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
animationsOff
|
animationsOff
|
||||||
? undefined
|
? undefined
|
||||||
: (reducedAnimations
|
: (reducedAnimations
|
||||||
? 'transition-[transform,width] duration-75 ease-linear'
|
? 'transition-[transform,width,height] duration-75 ease-linear'
|
||||||
: 'transition-[transform,width] duration-200 ease-out')
|
: 'transition-[transform,width,height] duration-200 ease-out')
|
||||||
|
const submenuItemMotion = animationsOff ? { } : {
|
||||||
|
initial: { x: verticalPreview ? -40 : 0,
|
||||||
|
y: verticalPreview ? 0 : -40, opacity: 0 },
|
||||||
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
|
exit: { x: verticalPreview ? -40 : 0,
|
||||||
|
y: verticalPreview ? 0 : -40, opacity: 0 } }
|
||||||
|
const submenuVariants = {
|
||||||
|
enter: (direction: -1 | 1) => ({
|
||||||
|
x: verticalPreview ? direction * 24 : 0,
|
||||||
|
y: verticalPreview ? 0 : direction * 24, opacity: 0 }),
|
||||||
|
centre: { x: 0, y: 0, opacity: 1 },
|
||||||
|
exit: (direction: -1 | 1) => ({
|
||||||
|
x: verticalPreview ? (-direction) * 24 : 0,
|
||||||
|
y: verticalPreview ? 0 : (-direction) * 24, opacity: 0 }) }
|
||||||
|
|
||||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||||
|
|
||||||
@@ -287,6 +306,14 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (verticalPreview)
|
||||||
|
{
|
||||||
|
setDesktop (true)
|
||||||
|
setDensity ('auto')
|
||||||
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Measure the same desktop elements while the mobile layout is active.
|
// Measure the same desktop elements while the mobile layout is active.
|
||||||
layout.setAttribute ('data-measuring', '')
|
layout.setAttribute ('data-measuring', '')
|
||||||
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
|
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
|
||||||
@@ -351,7 +378,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
removeEventListener ('resize', schedule)
|
removeEventListener ('resize', schedule)
|
||||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||||
}
|
}
|
||||||
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
|
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales,
|
||||||
|
menuLabels, verticalPreview])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (desktop == null || layoutReady)
|
if (desktop == null || layoutReady)
|
||||||
@@ -378,7 +406,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
measure (moreVsbl ? -1 : activeIdx)
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
if (!(desktop))
|
if (!(desktop))
|
||||||
setMoreVsbl (false)
|
setMoreVsbl (false)
|
||||||
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
|
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode,
|
||||||
|
menuLabels, verticalPreview])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
@@ -392,6 +421,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
|
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
|
||||||
|
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
|
||||||
}
|
}
|
||||||
const observer =
|
const observer =
|
||||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
|
||||||
@@ -403,7 +433,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
observer?.disconnect ()
|
observer?.disconnect ()
|
||||||
removeEventListener ('resize', update)
|
removeEventListener ('resize', update)
|
||||||
}
|
}
|
||||||
}, [activeIdx, moreVsbl, desktop, density, locale])
|
}, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setMenuOpen (false)
|
setMenuOpen (false)
|
||||||
@@ -415,17 +445,18 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
rounded px-1 focus-within:ring-2">
|
rounded px-1 focus-within:ring-2">
|
||||||
<Globe aria-hidden="true" size={16}/>
|
<Globe aria-hidden="true" size={16}/>
|
||||||
<span className="top-nav-locale-name whitespace-nowrap text-sm">
|
<span className="top-nav-locale-name whitespace-nowrap text-sm">
|
||||||
<LocalisedText language={locale}>{localeName}</LocalisedText>
|
<LocalisedText language={uiLocale}>{localeName}</LocalisedText>
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
aria-label={formatUIString (localeName ?? '', locale)}
|
aria-label={formatUIString (localeName ?? '', uiLocale)}
|
||||||
value={locale}
|
lang={uiLocale}
|
||||||
|
value={uiLocale}
|
||||||
disabled={switchingLocale}
|
disabled={switchingLocale}
|
||||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
onChange={async event => {
|
onChange={async event => {
|
||||||
const nextLocale = event.target.value
|
const nextLocale = event.target.value
|
||||||
if (isLocaleCode (nextLocale)
|
if (isUILocaleCode (nextLocale)
|
||||||
&& locales.some (entry => entry.code === nextLocale))
|
&& uiLocales.some (entry => entry.code === nextLocale))
|
||||||
{
|
{
|
||||||
const request = ++localeSwitchRequest.current
|
const request = ++localeSwitchRequest.current
|
||||||
setSwitchingLocale (true)
|
setSwitchingLocale (true)
|
||||||
@@ -450,8 +481,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{locales.map (entry => isLocaleCode (entry.code) && (
|
{uiLocales.map (entry => (
|
||||||
<option key={entry.code} value={entry.code}>
|
<option key={entry.code} value={entry.code} lang={entry.code}>
|
||||||
{formatUIString (entry.name, entry.code)}
|
{formatUIString (entry.name, entry.code)}
|
||||||
</option>))}
|
</option>))}
|
||||||
</select>
|
</select>
|
||||||
@@ -461,7 +492,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||||
data-desktop={desktop ?? undefined} data-density={density}
|
data-desktop={desktop ?? undefined} data-density={density}
|
||||||
data-layout-ready={layoutReady}>
|
data-layout-ready={layoutReady}>
|
||||||
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
|
<nav ref={barRef} lang={uiMessageLanguage (uiLocale, 'posts')}
|
||||||
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
||||||
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
@@ -479,8 +510,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
className={cn ('absolute inset-y-0 h-12',
|
className={cn ('absolute inset-y-0 h-12',
|
||||||
'top-nav-highlight',
|
'top-nav-highlight',
|
||||||
highlightTransitionClass)}
|
highlightTransitionClass)}
|
||||||
style={{ width: hl.width,
|
style={{ width: verticalPreview ? '100%' : hl.width,
|
||||||
transform: `translateX(${ hl.left }px)`,
|
height: verticalPreview ? hl.width : undefined,
|
||||||
|
transform: verticalPreview
|
||||||
|
? `translateY(${ hl.left }px)`
|
||||||
|
: `translateX(${ hl.left }px)`,
|
||||||
opacity: hl.visible ? 1 : 0 }}/>
|
opacity: hl.visible ? 1 : 0 }}/>
|
||||||
|
|
||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
@@ -554,7 +588,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
layout
|
layout
|
||||||
className="top-nav-desktop-submenu relative z-20 shrink-0
|
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||||
overflow-hidden top-nav-submenu"
|
overflow-hidden top-nav-submenu"
|
||||||
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
|
animate={verticalPreview && desktop
|
||||||
|
? { width: submenuWidth, height: '100%' }
|
||||||
|
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (moreVsbl)
|
if (moreVsbl)
|
||||||
setMoreVsbl (false)
|
setMoreVsbl (false)
|
||||||
@@ -566,7 +602,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
onLayoutAnimationComplete={() => {
|
onLayoutAnimationComplete={() => {
|
||||||
measure (highlightTargetRef.current)
|
measure (highlightTargetRef.current)
|
||||||
}}>
|
}}>
|
||||||
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
|
<div ref={submenuContentRef}
|
||||||
|
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
|
||||||
{moreVsbl
|
{moreVsbl
|
||||||
? (
|
? (
|
||||||
moreMenu.map ((item, i) => (
|
moreMenu.map ((item, i) => (
|
||||||
@@ -576,9 +613,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
{...((item.visible ?? true)
|
{...((item.visible ?? true)
|
||||||
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
||||||
: (animationsOff ? { }
|
: (animationsOff ? { }
|
||||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
: { initial: {
|
||||||
|
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
|
||||||
animate: { x: 0, y: 0, opacity: 1 },
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
exit: {
|
||||||
|
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
|
||||||
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||||
className="z-10 min-w-0 px-3 py-2 font-bold">
|
className="z-10 min-w-0 px-3 py-2 font-bold">
|
||||||
<h2>
|
<h2>
|
||||||
@@ -599,10 +638,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(visibleMenu[activeIdx] === item
|
{...(visibleMenu[activeIdx] === item
|
||||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||||
: (animationsOff ? { }
|
: submenuItemMotion)}>
|
||||||
: { initial: { y: -40, opacity: 0 },
|
|
||||||
animate: { y: 0, opacity: 1 },
|
|
||||||
exit: { y: -40, opacity: 0 } }))}>
|
|
||||||
{subItem.component}
|
{subItem.component}
|
||||||
</motion.div>)
|
</motion.div>)
|
||||||
: (
|
: (
|
||||||
@@ -612,10 +648,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(visibleMenu[activeIdx] === item
|
{...(visibleMenu[activeIdx] === item
|
||||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||||
: (animationsOff ? { }
|
: submenuItemMotion)}>
|
||||||
: { initial: { y: -40, opacity: 0 },
|
|
||||||
animate: { y: 0, opacity: 1 },
|
|
||||||
exit: { y: -40, opacity: 0 } }))}>
|
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={subItem.to}
|
to={subItem.to}
|
||||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||||
@@ -633,7 +666,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
<div className="relative min-h-[40px]">
|
<div className="relative min-h-[40px]">
|
||||||
{animationsOff
|
{animationsOff
|
||||||
? (
|
? (
|
||||||
<div className="flex min-h-[40px] flex-wrap items-center px-3">
|
<div className="top-nav-submenu-items flex min-h-[40px]
|
||||||
|
flex-wrap items-center px-3">
|
||||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
.filter (item => item.visible ?? true)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => (
|
.map ((item, i) => (
|
||||||
@@ -662,10 +696,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={activeIdx}
|
key={activeIdx}
|
||||||
custom={dir}
|
custom={dir}
|
||||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
variants={submenuVariants}
|
||||||
centre: { y: 0, opacity: 1 },
|
className="top-nav-submenu-items relative flex min-h-[40px]
|
||||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
flex-wrap items-center px-3"
|
||||||
className="relative flex min-h-[40px] flex-wrap items-center px-3"
|
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="centre"
|
animate="centre"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
|
|
||||||
|
|
||||||
const Form: FC<Props> = ({ children, className }) => (
|
const Form: FC<Props> = ({ children, className }) => (
|
||||||
<div className={cn ('mx-auto max-w-xl space-y-4 p-4', className)}>
|
<div className={cn ('form-layout mx-auto max-w-xl space-y-4 p-4', className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>)
|
</div>)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
const pages = getPages (page, totalPages, siblingCount)
|
const pages = getPages (page, totalPages, siblingCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="mt-4 flex justify-center"
|
<nav className="pagination mt-4 flex justify-center"
|
||||||
lang={message ('pagination.label').language}
|
lang={message ('pagination.label').language}
|
||||||
aria-label={message ('pagination.label').text}>
|
aria-label={message ('pagination.label').text}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import CandidatePicker from '@/components/common/CandidatePicker'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
|
||||||
|
|
||||||
import type { CandidateSource } from '@/components/common/CandidatePicker'
|
|
||||||
|
|
||||||
|
|
||||||
const ResourcePickerButton = (
|
|
||||||
{ source, values, maximum = 1, disabled, onSelect }: {
|
|
||||||
source: CandidateSource; values: (string | number | boolean)[]
|
|
||||||
maximum?: number; disabled?: boolean
|
|
||||||
onSelect: (values: string[]) => void },
|
|
||||||
) => {
|
|
||||||
const dialogue = useDialogue ()
|
|
||||||
const { label } = useUITranslation ('counts')
|
|
||||||
return <Button type="button" variant="outline" disabled={disabled || maximum === 0}
|
|
||||||
onClick={() => void dialogue.form ({
|
|
||||||
title: label ('pattern.link'), size: 'large', footerLayout: 'inline',
|
|
||||||
body: controls => <CandidatePicker source={source} initial={values}
|
|
||||||
maximum={maximum} controls={controls} onSelect={onSelect}/> })}>
|
|
||||||
{label ('pattern.selection')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default ResourcePickerButton
|
|
||||||
@@ -12,7 +12,7 @@ export default forwardRef<HTMLTextAreaElement, Props> (
|
|||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
className={cn ('rounded border w-full p-2 h-32',
|
className={cn ('rounded border [inline-size:100%] p-2 [block-size:8rem]',
|
||||||
(invalid
|
(invalid
|
||||||
? ['border-red-500 bg-red-50 text-red-900',
|
? ['border-red-500 bg-red-50 text-red-900',
|
||||||
'focus:border-red-500 focus:outline-none focus:ring-2',
|
'focus:border-red-500 focus:outline-none focus:ring-2',
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import CountsConditionValues from '@/components/counts/CountsConditionValues'
|
import CountsConditionValues from '@/components/counts/CountsConditionValues'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsCandidateSource, countsConditionInputs, countsEventTypes, countsFieldLabel,
|
import { countsConditionInputs, countsEventTypes, countsFieldLabel, countsFilterFields,
|
||||||
countsFilterFields, countsSetOperators, useCountsDefinition }
|
countsSetOperators, useCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { countsConditionValue } from '@/lib/countsDisplay'
|
import { countsConditionValue } from '@/lib/countsDisplay'
|
||||||
@@ -16,7 +15,7 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
|||||||
|
|
||||||
import { readCountState, encodeCountState } from '@/lib/queryState'
|
import { readCountState, encodeCountState } from '@/lib/queryState'
|
||||||
|
|
||||||
import type { CountsResource, CountsResult } from '@/lib/counts'
|
import type { CountsResult } from '@/lib/counts'
|
||||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
import type { CountsCondition, CountsDefinition } from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
@@ -95,9 +94,7 @@ const ConditionEditor = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const CountsAdditionalConditions = (
|
const CountsAdditionalConditions = ({ params, update, conditionLabels }: {
|
||||||
{ resource, params, update, conditionLabels }: {
|
|
||||||
resource: CountsResource
|
|
||||||
conditionLabels?: CountsResult['conditionLabels']
|
conditionLabels?: CountsResult['conditionLabels']
|
||||||
params: URLSearchParams
|
params: URLSearchParams
|
||||||
update: (key: string, value: string) => void },
|
update: (key: string, value: string) => void },
|
||||||
@@ -152,19 +149,11 @@ const CountsAdditionalConditions = (
|
|||||||
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
|
value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
|
||||||
aria-label={aria.text} lang={aria.language}
|
aria-label={aria.text} lang={aria.language}
|
||||||
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
onChange={value => patch (index, { [key]: value ?? '' })}/>
|
||||||
const source = countsCandidateSource (resource, condition.field, params)
|
return <input value={condition[key] == null ? '' : String (condition[key])} disabled={condition.blank}
|
||||||
const value = condition[key]
|
|
||||||
return <>
|
|
||||||
<input value={condition[key] == null ? '' : String (condition[key])}
|
|
||||||
disabled={condition.blank}
|
|
||||||
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
type={(type === 'integer' || type === 'interval') ? 'number' : 'text'}
|
||||||
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
|
className={inputClass (false, 'h-10 w-36 min-w-0 max-w-full py-1')}
|
||||||
aria-label={aria.text} lang={aria.language}
|
aria-label={aria.text} lang={aria.language}
|
||||||
onChange={event => patch (index, { [key]: event.target.value })}/>
|
onChange={event => patch (index, { [key]: event.target.value })}/>
|
||||||
{source != null && <ResourcePickerButton source={source} disabled={condition.blank}
|
|
||||||
values={value == null ? [] : [value]}
|
|
||||||
onSelect={values => patch (index, { [key]: values[0] ?? '' })}/>}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
return <fieldset className="min-w-0 space-y-1">
|
return <fieldset className="min-w-0 space-y-1">
|
||||||
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
||||||
@@ -193,7 +182,7 @@ const CountsAdditionalConditions = (
|
|||||||
</Button>
|
</Button>
|
||||||
{countsSetOperators.includes (condition.operator) ? (
|
{countsSetOperators.includes (condition.operator) ? (
|
||||||
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
|
<CountsConditionValues key={`${condition.field}:${condition.operator}`}
|
||||||
condition={condition} resource={resource}
|
condition={condition}
|
||||||
field={metadata.find (field => field.key === condition.field)!}
|
field={metadata.find (field => field.key === condition.field)!}
|
||||||
definition={definition} params={params}
|
definition={definition} params={params}
|
||||||
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
|
maximum={Math.max (0, 100 - conditions.reduce ((sum, item, position) =>
|
||||||
|
|||||||
+31
-46
@@ -10,19 +10,16 @@ import { useUITranslation } from '@/lib/useUITranslation'
|
|||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
import type { CountsResource } from '@/lib/counts'
|
import type { CountsCandidate, CountsDefinition, CountsFieldMetadata }
|
||||||
import type { CountsCandidate } from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
type CandidateSource = {
|
const CountsCandidatePicker = (
|
||||||
resource: CountsResource; field: string; params?: URLSearchParams }
|
{ field, definition, params, initial, maximum, controls, save }: {
|
||||||
|
field: CountsFieldMetadata; params: URLSearchParams
|
||||||
const CandidatePicker = (
|
definition: CountsDefinition
|
||||||
{ source, choices = [], categories = [], initial, maximum, controls, onSelect }: {
|
|
||||||
source?: CandidateSource; choices?: CountsCandidate[]
|
|
||||||
categories?: { value: string; label: string }[]
|
|
||||||
initial: (string | number | boolean)[]; maximum: number
|
initial: (string | number | boolean)[]; maximum: number
|
||||||
controls: DialogueFormControls; onSelect: (values: string[]) => void },
|
controls: DialogueFormControls; save: (values: string[]) => void },
|
||||||
) => {
|
) => {
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
@@ -32,58 +29,49 @@ const CandidatePicker = (
|
|||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
const [category, setCategory] = useState ('')
|
const [category, setCategory] = useState ('')
|
||||||
const [page, setPage] = useState (1)
|
const [page, setPage] = useState (1)
|
||||||
const params = source?.params ?? new URLSearchParams ()
|
|
||||||
const point = useRef (params.get ('execution_at'))
|
const point = useRef (params.get ('execution_at'))
|
||||||
const resource = source?.resource ?? 'posts'
|
const request = new URLSearchParams ({ field: field.key, query: search, page: String (page) })
|
||||||
const request = new URLSearchParams ({
|
|
||||||
field: source?.field ?? '', query: search, page: String (page) })
|
|
||||||
for (const key of ['timezone', 'at', 'execution_at'])
|
for (const key of ['timezone', 'at', 'execution_at'])
|
||||||
if (params.has (key)) request.set (key, params.get (key)!)
|
if (params.has (key)) request.set (key, params.get (key)!)
|
||||||
if (countsUseLatest (params)) request.delete ('at')
|
if (countsUseLatest (params)) request.delete ('at')
|
||||||
if (category) request.set ('category', category)
|
if (category) request.set ('category', category)
|
||||||
const dynamic = source != null
|
const dynamic = field.candidateSource === 'tags'
|
||||||
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
const { data, isFetching: fetching, isError: errorFlg } = useQuery ({
|
||||||
queryKey: countsKeys.candidates (resource, request.toString (), locale), enabled: dynamic,
|
queryKey: countsKeys.candidates (request.toString (), locale), enabled: dynamic,
|
||||||
queryFn: ({ signal }) => {
|
queryFn: ({ signal }) => {
|
||||||
const pinned = new URLSearchParams (request)
|
const pinned = new URLSearchParams (request)
|
||||||
if (point.current != null) pinned.set ('execution_at', point.current)
|
if (point.current != null) pinned.set ('execution_at', point.current)
|
||||||
return fetchCountsCandidates (resource, pinned, locale, signal)
|
return fetchCountsCandidates (pinned, locale, signal)
|
||||||
}, retry: false, staleTime: 0, gcTime: 0 })
|
}, retry: false })
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (data != null) point.current = data.executionAt
|
if (data != null) point.current = data.executionAt
|
||||||
}, [data])
|
}, [data])
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
controls.setActions ([{ label: label ('counts:pattern.selection'),
|
controls.setActions ([{ label: label ('counts:pattern.selection'),
|
||||||
onSelect: () => onSelect ([...selected]) }])
|
onSelect: () => save ([...selected]) }])
|
||||||
}, [controls, label, onSelect, selected])
|
}, [controls, label, save, selected])
|
||||||
const rows = dynamic ? data?.rows ?? [] : choices.filter (row =>
|
const fixed: CountsCandidate[] = (field.choices ?? []).map (choice => ({
|
||||||
|
value: choice.value, name: message (choice.label).text }))
|
||||||
|
const rows = dynamic ? data?.rows ?? [] : fixed.filter (row =>
|
||||||
row.name?.includes (search))
|
row.name?.includes (search))
|
||||||
const headings: Record<string, string> = {
|
const headings: Record<string, string> = {
|
||||||
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
id: 'columns.resourceId', name: 'columns.name', category: 'common:categoryLabel',
|
||||||
kind: 'columns.kind' }
|
kind: 'columns.kind' }
|
||||||
const referenceFlg = ['tags', 'tags_json', 'tags.id', 'members'].includes (source?.field ?? '')
|
const available = dynamic ? ['id', 'name', 'category', 'kind'] : ['name']
|
||||||
const available =
|
|
||||||
dynamic
|
|
||||||
? (referenceFlg ? ['id', 'name', 'category', 'kind'] : ['id', 'name']) : ['name']
|
|
||||||
const visible = available.filter (column => columns.includes (column))
|
const visible = available.filter (column => columns.includes (column))
|
||||||
|
const categories = definition.filterFields?.find (field =>
|
||||||
|
field.candidateSource === 'categories')?.choices ?? []
|
||||||
const cell = (row: CountsCandidate, column: string) => {
|
const cell = (row: CountsCandidate, column: string) => {
|
||||||
if (column === 'category')
|
if (column === 'category') return row.category == null ? ''
|
||||||
return row.category == null ? '' : message (`common:categories.${row.category}`).text
|
: message (`common:categories.${row.category}`).text
|
||||||
if (column === 'kind')
|
if (column === 'kind') return row.kind == null ? '' : message (`kinds.${row.kind}`).text
|
||||||
{
|
|
||||||
const key = row.kind === 'group' ? 'resources.tag_groups' : `kinds.${row.kind}`
|
|
||||||
return row.kind == null ? '' : message (key).text
|
|
||||||
}
|
|
||||||
return column === 'id' ? row.id : row.name
|
return column === 'id' ? row.id : row.name
|
||||||
}
|
}
|
||||||
return <div className="min-w-0 space-y-2">
|
return <div className="min-w-0 space-y-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
|
<input className={inputClass (false, 'h-9 min-w-0 flex-1')}
|
||||||
aria-label={message ('common:search').text} value={draft} maxLength={1000}
|
aria-label={message ('common:search').text} value={draft}
|
||||||
onChange={event => {
|
onChange={event => setDraft (event.target.value)}
|
||||||
const value = event.target.value
|
|
||||||
if (new TextEncoder ().encode (value).length <= 1000) setDraft (value)
|
|
||||||
}}
|
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (event.key !== 'Enter') return
|
if (event.key !== 'Enter') return
|
||||||
event.preventDefault ()
|
event.preventDefault ()
|
||||||
@@ -93,7 +81,7 @@ const CandidatePicker = (
|
|||||||
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
|
<Button type="button" onClick={() => { setSearch (draft); setPage (1) }}>
|
||||||
{label ('common:search')}</Button>
|
{label ('common:search')}</Button>
|
||||||
</div>
|
</div>
|
||||||
{categories.length > 0 && <select className={inputClass (false, 'h-9 w-full')}
|
{dynamic && <select className={inputClass (false, 'h-9 w-full')}
|
||||||
aria-label={message ('common:categoryLabel').text} value={category}
|
aria-label={message ('common:categoryLabel').text} value={category}
|
||||||
onChange={event => { setCategory (event.target.value); setPage (1) }}>
|
onChange={event => { setCategory (event.target.value); setPage (1) }}>
|
||||||
<option value=""/>
|
<option value=""/>
|
||||||
@@ -120,13 +108,12 @@ const CandidatePicker = (
|
|||||||
{visible.map (column => <th key={column} className="p-2">
|
{visible.map (column => <th key={column} className="p-2">
|
||||||
{label (headings[column])}</th>)}
|
{label (headings[column])}</th>)}
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{rows.map (row => <tr key={`${row.kind ?? ''}:${row.value}`}
|
<tbody>{rows.map (row => <tr key={row.value} className="border-t dark:border-slate-700">
|
||||||
className="border-t dark:border-slate-700">
|
|
||||||
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
|
<td className="p-2"><input type="checkbox" checked={selected.has (row.value)}
|
||||||
aria-label={row.name ?? String (row.id ?? '')}
|
aria-label={row.name ?? String (row.id ?? '')}
|
||||||
disabled={maximum !== 1 && !selected.has (row.value) && selected.size >= maximum}
|
disabled={!selected.has (row.value) && selected.size >= maximum}
|
||||||
onChange={event => setSelected (previous => {
|
onChange={event => setSelected (previous => {
|
||||||
const next = new Set (maximum === 1 ? [] : previous)
|
const next = new Set (previous)
|
||||||
if (event.target.checked) next.add (row.value)
|
if (event.target.checked) next.add (row.value)
|
||||||
else next.delete (row.value)
|
else next.delete (row.value)
|
||||||
return next
|
return next
|
||||||
@@ -141,8 +128,7 @@ const CandidatePicker = (
|
|||||||
onClick={() => setPage (previous => previous - 1)}>
|
onClick={() => setPage (previous => previous - 1)}>
|
||||||
{label ('common:pagination.previous')}</Button>
|
{label ('common:pagination.previous')}</Button>
|
||||||
<span>{page}</span>
|
<span>{page}</span>
|
||||||
<Button type="button" variant="outline"
|
<Button type="button" variant="outline" disabled={!data?.more || fetching}
|
||||||
disabled={!data?.more || page >= 100_000 || fetching}
|
|
||||||
onClick={() => setPage (previous => previous + 1)}>
|
onClick={() => setPage (previous => previous + 1)}>
|
||||||
{label ('common:pagination.next')}</Button>
|
{label ('common:pagination.next')}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -150,5 +136,4 @@ const CandidatePicker = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default CandidatePicker
|
export default CountsCandidatePicker
|
||||||
export type { CandidateSource }
|
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import CandidatePicker from '@/components/common/CandidatePicker'
|
import CountsCandidatePicker from '@/components/counts/CountsCandidatePicker'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsCandidateSource } from '@/lib/countsDefinitions'
|
|
||||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { CountsResource } from '@/lib/counts'
|
|
||||||
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
|
import type { CountsCondition, CountsDefinition, CountsFieldMetadata }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
|
|
||||||
|
|
||||||
const CountsConditionValues = (
|
const CountsConditionValues = (
|
||||||
{ resource, condition, field, definition, params, maximum, change }: {
|
{ condition, field, definition, params, maximum, change }: {
|
||||||
resource: CountsResource
|
|
||||||
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
|
condition: CountsCondition; field: CountsFieldMetadata; definition: CountsDefinition
|
||||||
params: URLSearchParams; maximum: number
|
params: URLSearchParams; maximum: number
|
||||||
change: (values: (string | number | boolean)[]) => void },
|
change: (values: (string | number | boolean)[]) => void },
|
||||||
@@ -25,21 +21,11 @@ const CountsConditionValues = (
|
|||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
const [draft, setDraft] = useState ('')
|
const [draft, setDraft] = useState ('')
|
||||||
const values = condition.values ?? []
|
const values = condition.values ?? []
|
||||||
const source = countsCandidateSource (resource, field.key, params)
|
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
void dialogue.form ({ title: label (field.candidateSource === 'tags'
|
||||||
? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline',
|
? 'counts:pattern.link' : 'counts:pattern.pattern'), size: 'large', footerLayout: 'inline',
|
||||||
body: controls => <CandidatePicker
|
body: controls => <CountsCandidatePicker field={field} definition={definition}
|
||||||
source={
|
params={params} initial={values} maximum={maximum} controls={controls} save={change}/> })
|
||||||
field.candidateSource === 'tags'
|
|
||||||
? { resource: 'posts', field: field.key, params } : undefined}
|
|
||||||
choices={(field.choices ?? []).map (choice => ({
|
|
||||||
value: choice.value, name: message (choice.label).text }))}
|
|
||||||
categories={
|
|
||||||
field.candidateSource === 'tags'
|
|
||||||
? definition.filterFields?.find (field =>
|
|
||||||
field.candidateSource === 'categories')?.choices : undefined}
|
|
||||||
initial={values} maximum={maximum} controls={controls} onSelect={change}/> })
|
|
||||||
}
|
}
|
||||||
if (condition.input === 'list' || field.candidateSource === 'tags')
|
if (condition.input === 'list' || field.candidateSource === 'tags')
|
||||||
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
|
return <Button type="button" variant="outline" disabled={condition.blank} onClick={pick}>
|
||||||
@@ -87,8 +73,6 @@ const CountsConditionValues = (
|
|||||||
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
|
onClick={() => change (values.filter ((_, position) => position !== index))}>×</Button>
|
||||||
</span>)}
|
</span>)}
|
||||||
{input ()}
|
{input ()}
|
||||||
{source != null && <ResourcePickerButton source={source} values={values}
|
|
||||||
maximum={maximum} disabled={condition.blank} onSelect={change}/>}
|
|
||||||
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|
<Button type="button" variant="outline" disabled={condition.blank || draft === ''
|
||||||
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
|
|| values.length >= maximum} onClick={append}>{label ('add')}</Button>
|
||||||
<Button type="button" variant="ghost" disabled={condition.blank}
|
<Button type="button" variant="ghost" disabled={condition.blank}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import AnimatedCollapse from '@/components/common/AnimatedCollapse'
|
|||||||
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 HelpPopover from '@/components/common/HelpPopover'
|
import HelpPopover from '@/components/common/HelpPopover'
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import TagInput from '@/components/common/TagInput'
|
import TagInput from '@/components/common/TagInput'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
import { countsRequestParams, countsUseLatest, fieldsForCounts, normaliseCountsParams, readCountSort,
|
||||||
@@ -224,17 +223,11 @@ const CountsControls = (
|
|||||||
{['resource_id', 'user_id'].map (key => <FormField key={key}
|
{['resource_id', 'user_id'].map (key => <FormField key={key}
|
||||||
label={label (key === 'resource_id' ? 'columns.resourceId'
|
label={label (key === 'resource_id' ? 'columns.resourceId'
|
||||||
: 'fields.actor')}>
|
: 'fields.actor')}>
|
||||||
{() => <div className="flex flex-wrap items-center gap-1">
|
{() => <input value={draft.get (key) ?? ''}
|
||||||
<input value={draft.get (key) ?? ''}
|
|
||||||
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
|
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
|
||||||
: 'fields.actor').text}
|
: 'fields.actor').text}
|
||||||
className={inputClass (false, 'w-28 py-1')}
|
className={inputClass (false, 'w-28 py-1')}
|
||||||
onChange={event => update (key, event.target.value)}/>
|
onChange={event => update (key, event.target.value)}/>}
|
||||||
<ResourcePickerButton source={{ resource,
|
|
||||||
field: key === 'resource_id' ? key : 'user', params: draft }}
|
|
||||||
values={draft.get (key) ? [draft.get (key)!] : []}
|
|
||||||
onSelect={values => update (key, values[0] ?? '')}/>
|
|
||||||
</div>}
|
|
||||||
</FormField>)}
|
</FormField>)}
|
||||||
</div>
|
</div>
|
||||||
{resource === 'posts' && <FormField label={label ('columns.tags')}>
|
{resource === 'posts' && <FormField label={label ('columns.tags')}>
|
||||||
@@ -270,7 +263,7 @@ const CountsControls = (
|
|||||||
</>}
|
</>}
|
||||||
</div>}
|
</div>}
|
||||||
</FormField>}
|
</FormField>}
|
||||||
<CountsAdditionalConditions resource={resource} params={draft} update={update}
|
<CountsAdditionalConditions params={draft} update={update}
|
||||||
conditionLabels={(labelParams != null
|
conditionLabels={(labelParams != null
|
||||||
&& draft.get ('current') === labelParams.get ('current')
|
&& draft.get ('current') === labelParams.get ('current')
|
||||||
&& draft.get ('at') === labelParams.get ('at')
|
&& draft.get ('at') === labelParams.get ('at')
|
||||||
|
|||||||
@@ -227,13 +227,11 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
<DialogueProvider key={active.id}>
|
|
||||||
{active.options.body (
|
{active.options.body (
|
||||||
formControls.current[active.id] ??= {
|
formControls.current[active.id] ??= {
|
||||||
close: () => closeRequest (active.id),
|
close: () => closeRequest (active.id),
|
||||||
setActions: actions => setRequestActions (active.id, actions),
|
setActions: actions => setRequestActions (active.id, actions),
|
||||||
confirm: options => openNestedConfirm (active.id, options) })}
|
confirm: options => openNestedConfirm (active.id, options) })}
|
||||||
</DialogueProvider>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter
|
<DialogFooter
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
|
import { useVerticalPreview } from '@/lib/useLocale'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -9,21 +12,71 @@ import type { FC, ReactNode } from 'react'
|
|||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
animateLayout?: boolean }
|
animateLayout?: boolean
|
||||||
|
verticalContent?: boolean }
|
||||||
|
|
||||||
|
|
||||||
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
|
const MainArea: FC<Props> = (
|
||||||
|
{ children, className, animateLayout = true, verticalContent = true },
|
||||||
|
) => {
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
|
const verticalPreview = useVerticalPreview ()
|
||||||
|
const horizontal = verticalPreview && verticalContent
|
||||||
|
const { pathname, search } = useLocation ()
|
||||||
|
const rootRef = useRef<HTMLElement | null> (null)
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
animationMode,
|
animationMode,
|
||||||
{ normal: { duration: .2, ease: 'easeOut' as const } },
|
{ normal: { duration: .2, ease: 'easeOut' as const } })
|
||||||
)
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (horizontal && rootRef.current != null)
|
||||||
|
rootRef.current.scrollLeft = 0
|
||||||
|
}, [horizontal, pathname, search])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const root = rootRef.current
|
||||||
|
if (!horizontal || root == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
if (event.ctrlKey || event.metaKey || event.shiftKey
|
||||||
|
|| event.deltaX !== 0 || event.deltaY === 0 || event.defaultPrevented)
|
||||||
|
return
|
||||||
|
|
||||||
|
// Nested tables, forms and code blocks retain their own scrolling.
|
||||||
|
let node = event.target instanceof HTMLElement ? event.target : null
|
||||||
|
while (node != null && node !== root)
|
||||||
|
{
|
||||||
|
if (node.matches ('input, select, textarea, [contenteditable="true"]'))
|
||||||
|
return
|
||||||
|
const style = getComputedStyle (node)
|
||||||
|
if ((/auto|scroll/.test (style.overflowY) && node.scrollHeight > node.clientHeight)
|
||||||
|
|| (/auto|scroll/.test (style.overflowX) && node.scrollWidth > node.clientWidth))
|
||||||
|
return
|
||||||
|
node = node.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? root.clientWidth : 1)
|
||||||
|
const delta = event.deltaY * unit
|
||||||
|
if ((delta < 0 && root.scrollLeft <= 0)
|
||||||
|
|| (delta > 0 && root.scrollLeft + root.clientWidth >= root.scrollWidth))
|
||||||
|
return
|
||||||
|
event.preventDefault ()
|
||||||
|
root.scrollLeft += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addEventListener ('wheel', handleWheel, { passive: false })
|
||||||
|
return () => root.removeEventListener ('wheel', handleWheel)
|
||||||
|
}, [horizontal])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.main
|
<motion.main
|
||||||
|
ref={rootRef}
|
||||||
|
data-vertical-content={horizontal || undefined}
|
||||||
|
tabIndex={horizontal ? 0 : undefined}
|
||||||
transition={{ layout: layoutTransition }}
|
transition={{ layout: layoutTransition }}
|
||||||
className={cn ('flex-1 overflow-y-auto p-4', className)}
|
className={cn ('main-area flex-1 overflow-y-auto p-4', className)}
|
||||||
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
|
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
|
||||||
{children}
|
{children}
|
||||||
</motion.main>)
|
</motion.main>)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { useVerticalPreview } from '@/lib/useLocale'
|
||||||
|
|
||||||
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
type SidebarSide = 'left' | 'right'
|
type SidebarSide = 'left' | 'right'
|
||||||
|
|
||||||
@@ -23,12 +24,12 @@ type Props = {
|
|||||||
|
|
||||||
type ResizeState = {
|
type ResizeState = {
|
||||||
pointerId: number
|
pointerId: number
|
||||||
startWidth: number
|
startSize: number
|
||||||
startX: number
|
startX: number
|
||||||
|
startY: number
|
||||||
|
verticalPreview: boolean
|
||||||
side: SidebarSide }
|
side: SidebarSide }
|
||||||
|
|
||||||
type PendingPressState = ResizeState & { startY: number }
|
|
||||||
|
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||||
const MIN_SIDEBAR_WIDTH = 192
|
const MIN_SIDEBAR_WIDTH = 192
|
||||||
const MIN_MAIN_WIDTH = 360
|
const MIN_MAIN_WIDTH = 360
|
||||||
@@ -37,84 +38,97 @@ const LONG_PRESS_DELAY_MS = 350
|
|||||||
const TOUCH_MOVE_THRESHOLD_PX = 8
|
const TOUCH_MOVE_THRESHOLD_PX = 8
|
||||||
|
|
||||||
|
|
||||||
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
|
const storageKeyForSidebar = (
|
||||||
`sidebar:${ sidebarKey }:${ side }:width`
|
sidebarKey: string,
|
||||||
|
side: SidebarSide,
|
||||||
|
verticalPreview = false,
|
||||||
|
): string =>
|
||||||
|
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
|
||||||
|
|
||||||
|
|
||||||
const getSidebarMaxWidth = (containerWidth: number): number => {
|
const getSidebarMaxWidth = (containerSize: number): number => {
|
||||||
const viewportWidth =
|
const viewportWidth =
|
||||||
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
typeof window === 'undefined' ? containerSize : window.innerWidth
|
||||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
||||||
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
|
const containerMaxWidth = Math.floor (containerSize - MIN_MAIN_WIDTH)
|
||||||
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
|
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
|
||||||
|
|
||||||
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
|
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const readStoredSidebarWidth = (
|
const readStoredSidebarSize = (
|
||||||
sidebarKey: string,
|
sidebarKey: string,
|
||||||
side: SidebarSide): number => {
|
side: SidebarSide,
|
||||||
|
verticalPreview: boolean,
|
||||||
|
): number => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return DEFAULT_SIDEBAR_WIDTH
|
return DEFAULT_SIDEBAR_WIDTH
|
||||||
|
|
||||||
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
|
try
|
||||||
const width = value == null ? Number.NaN : Number (value)
|
{
|
||||||
|
const value = window.localStorage.getItem (
|
||||||
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
|
storageKeyForSidebar (sidebarKey, side, verticalPreview))
|
||||||
|
const size = value == null ? Number.NaN : Number (value)
|
||||||
|
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return DEFAULT_SIDEBAR_WIDTH
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SidebarComponent: FC<Props> = ({
|
const SidebarComponent: FC<Props> = (
|
||||||
children,
|
{ children, className, maxWidth, maxWidthRatio, animateLayout = true,
|
||||||
className,
|
onWidthChange, sidebarKey, side = 'left' },
|
||||||
maxWidth,
|
) => {
|
||||||
maxWidthRatio,
|
|
||||||
animateLayout = true,
|
|
||||||
onWidthChange,
|
|
||||||
sidebarKey,
|
|
||||||
side = 'left',
|
|
||||||
}) => {
|
|
||||||
const { message } = useUITranslation ('shared')
|
const { message } = useUITranslation ('shared')
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
|
const verticalPreview = useVerticalPreview ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
animationMode,
|
animationMode,
|
||||||
{ normal: { duration: .2, ease: 'easeOut' as const } },
|
{ normal: { duration: .2, ease: 'easeOut' as const } })
|
||||||
)
|
|
||||||
const rootRef = useRef<HTMLDivElement | null> (null)
|
const rootRef = useRef<HTMLDivElement | null> (null)
|
||||||
const maxWidthRef = useRef (maxWidth)
|
const maxWidthRef = useRef (maxWidth)
|
||||||
const maxWidthRatioRef = useRef (maxWidthRatio)
|
const maxWidthRatioRef = useRef (maxWidthRatio)
|
||||||
const getContainerWidth = useCallback (() => {
|
const getContainerSize = useCallback (() => {
|
||||||
const containerWidth = rootRef.current
|
const container = rootRef.current?.closest ('[data-sidebar-container]')
|
||||||
?.closest ('[data-sidebar-container]')
|
return verticalPreview
|
||||||
?.clientWidth
|
? container?.clientHeight ?? window.innerHeight
|
||||||
?? window.innerWidth
|
: container?.clientWidth ?? window.innerWidth
|
||||||
return containerWidth
|
}, [verticalPreview])
|
||||||
}, [])
|
const getClampedSize = useCallback ((
|
||||||
const getClampedWidth = useCallback ((
|
nextSize: number,
|
||||||
nextWidth: number,
|
containerSize: number,
|
||||||
containerWidth: number): number => {
|
): number => {
|
||||||
|
if (verticalPreview)
|
||||||
|
{
|
||||||
|
const maximum = Math.max (64, Math.min (
|
||||||
|
window.innerHeight * .6, containerSize - 160))
|
||||||
|
return Math.min (maximum, Math.max (64, nextSize))
|
||||||
|
}
|
||||||
const viewportWidth =
|
const viewportWidth =
|
||||||
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
typeof window === 'undefined' ? containerSize : window.innerWidth
|
||||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
||||||
const ratioMaxWidth = maxWidthRatioRef.current == null
|
const ratioMaxWidth = maxWidthRatioRef.current == null
|
||||||
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
|
? Infinity : Math.floor (containerSize * maxWidthRatioRef.current)
|
||||||
const configuredMaxWidth = maxWidthRef.current == null
|
const configuredMaxWidth = maxWidthRef.current == null
|
||||||
? getSidebarMaxWidth (containerWidth)
|
? getSidebarMaxWidth (containerSize)
|
||||||
: Math.max (MIN_SIDEBAR_WIDTH,
|
: Math.max (MIN_SIDEBAR_WIDTH,
|
||||||
Math.min (viewportMaxWidth, maxWidthRef.current))
|
Math.min (viewportMaxWidth, maxWidthRef.current))
|
||||||
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
|
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
|
||||||
Math.min (ratioMaxWidth, configuredMaxWidth))
|
Math.min (ratioMaxWidth, configuredMaxWidth))
|
||||||
|
|
||||||
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize))
|
||||||
}, [])
|
}, [verticalPreview])
|
||||||
|
|
||||||
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
|
const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH)
|
||||||
const [resizing, setResizing] = useState (false)
|
const [resizing, setResizing] = useState (false)
|
||||||
const widthRef = useRef (width)
|
const sizeRef = useRef (size)
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||||
const pendingPressRef = useRef<PendingPressState | null> (null)
|
const pendingPressRef = useRef<ResizeState | null> (null)
|
||||||
const resizeRef = useRef<ResizeState | null> (null)
|
const resizeRef = useRef<ResizeState | null> (null)
|
||||||
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
|
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
|
||||||
|
|
||||||
@@ -148,7 +162,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
userSelect: document.body.style.userSelect }
|
userSelect: document.body.style.userSelect }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.style.cursor = 'col-resize'
|
document.body.style.cursor = state.verticalPreview ? 'row-resize' : 'col-resize'
|
||||||
document.body.style.userSelect = 'none'
|
document.body.style.userSelect = 'none'
|
||||||
}, [clearLongPressTimer])
|
}, [clearLongPressTimer])
|
||||||
|
|
||||||
@@ -159,43 +173,50 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
const state = resizeRef.current
|
const state = resizeRef.current
|
||||||
resizeRef.current = null
|
resizeRef.current = null
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
if (state != null)
|
if (state != null)
|
||||||
window.localStorage.setItem (
|
window.localStorage.setItem (
|
||||||
storageKeyForSidebar (sidebarKey, state.side),
|
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
|
||||||
String (widthRef.current))
|
String (sizeRef.current))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Resizing remains usable when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
setResizing (false)
|
setResizing (false)
|
||||||
restoreBodyStyle ()
|
restoreBodyStyle ()
|
||||||
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
|
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
|
||||||
|
|
||||||
const applyWidth = useCallback ((nextWidth: number) => {
|
const applySize = useCallback ((nextSize: number) => {
|
||||||
if (nextWidth === widthRef.current)
|
if (nextSize === sizeRef.current)
|
||||||
return
|
return
|
||||||
|
|
||||||
widthRef.current = nextWidth
|
sizeRef.current = nextSize
|
||||||
setWidth (nextWidth)
|
setSize (nextSize)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
maxWidthRef.current = maxWidth
|
maxWidthRef.current = maxWidth
|
||||||
maxWidthRatioRef.current = maxWidthRatio
|
maxWidthRatioRef.current = maxWidthRatio
|
||||||
const containerWidth = getContainerWidth ()
|
const containerSize = getContainerSize ()
|
||||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
||||||
applyWidth (nextWidth)
|
applySize (nextSize)
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
|
}, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio])
|
||||||
|
|
||||||
const updateWidth = useCallback ((clientX: number) => {
|
const updateSize = useCallback ((clientX: number, clientY: number) => {
|
||||||
const state = resizeRef.current
|
const state = resizeRef.current
|
||||||
if (state == null)
|
if (state == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
const containerWidth = getContainerWidth ()
|
const containerSize = getContainerSize ()
|
||||||
const delta = state.side === 'left'
|
const delta = state.verticalPreview
|
||||||
? clientX - state.startX
|
? clientY - state.startY
|
||||||
: state.startX - clientX
|
: (state.side === 'left' ? clientX - state.startX : state.startX - clientX)
|
||||||
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
|
const nextSize = getClampedSize (state.startSize + delta, containerSize)
|
||||||
applyWidth (nextWidth)
|
applySize (nextSize)
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth])
|
}, [applySize, getClampedSize, getContainerSize])
|
||||||
|
|
||||||
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
|
||||||
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
|
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
|
||||||
@@ -211,8 +232,10 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
const state = {
|
const state = {
|
||||||
pointerId: ev.pointerId,
|
pointerId: ev.pointerId,
|
||||||
side,
|
side,
|
||||||
startWidth: widthRef.current,
|
startSize: sizeRef.current,
|
||||||
startX: ev.clientX }
|
startX: ev.clientX,
|
||||||
|
startY: ev.clientY,
|
||||||
|
verticalPreview }
|
||||||
|
|
||||||
if (ev.pointerType === 'mouse')
|
if (ev.pointerType === 'mouse')
|
||||||
{
|
{
|
||||||
@@ -220,7 +243,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingPressRef.current = { ...state, startY: ev.clientY }
|
pendingPressRef.current = state
|
||||||
clearLongPressTimer ()
|
clearLongPressTimer ()
|
||||||
longPressTimerRef.current = setTimeout (() => {
|
longPressTimerRef.current = setTimeout (() => {
|
||||||
if (pendingPressRef.current != null)
|
if (pendingPressRef.current != null)
|
||||||
@@ -234,7 +257,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
if (resizeRef.current != null)
|
if (resizeRef.current != null)
|
||||||
{
|
{
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
updateWidth (ev.clientX)
|
updateSize (ev.clientX, ev.clientY)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,58 +285,58 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
finishResize ()
|
finishResize ()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetWidth = useCallback (() => {
|
const resetSize = useCallback (() => {
|
||||||
const containerWidth = getContainerWidth ()
|
const containerSize = getContainerSize ()
|
||||||
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
|
const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize)
|
||||||
applyWidth (nextWidth)
|
applySize (nextSize)
|
||||||
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
|
window.localStorage.removeItem (
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
|
storageKeyForSidebar (sidebarKey, side, verticalPreview))
|
||||||
|
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
|
||||||
|
|
||||||
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
|
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
ev.stopPropagation ()
|
ev.stopPropagation ()
|
||||||
resetWidth ()
|
resetSize ()
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const containerWidth = getContainerWidth ()
|
const containerSize = getContainerSize ()
|
||||||
const nextWidth = getClampedWidth (
|
const nextSize = getClampedSize (
|
||||||
readStoredSidebarWidth (sidebarKey, side),
|
readStoredSidebarSize (sidebarKey, side, verticalPreview),
|
||||||
containerWidth)
|
containerSize)
|
||||||
applyWidth (nextWidth)
|
applySize (nextSize)
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
|
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const containerWidth = getContainerWidth ()
|
const containerSize = getContainerSize ()
|
||||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
||||||
applyWidth (nextWidth)
|
applySize (nextSize)
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth])
|
}, [applySize, getClampedSize, getContainerSize])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return
|
return
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
const containerWidth = getContainerWidth ()
|
const containerSize = getContainerSize ()
|
||||||
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
||||||
applyWidth (nextWidth)
|
applySize (nextSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener ('resize', handleResize)
|
window.addEventListener ('resize', handleResize)
|
||||||
return () => window.removeEventListener ('resize', handleResize)
|
return () => window.removeEventListener ('resize', handleResize)
|
||||||
}, [applyWidth, getClampedWidth, getContainerWidth])
|
}, [applySize, getClampedSize, getContainerSize])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
onWidthChange?.(width)
|
if (!verticalPreview)
|
||||||
}, [onWidthChange, width])
|
onWidthChange?.(size)
|
||||||
|
}, [onWidthChange, size, verticalPreview])
|
||||||
|
|
||||||
useEffect (() => () => {
|
useEffect (() => () => finishResize (), [finishResize, verticalPreview])
|
||||||
clearLongPressTimer ()
|
|
||||||
restoreBodyStyle ()
|
|
||||||
}, [clearLongPressTimer, restoreBodyStyle])
|
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
'--sidebar-width': `${ width }px` } as CSSProperties
|
'--sidebar-width': `${ size }px`,
|
||||||
|
'--sidebar-height': `${ size }px` } as CSSProperties
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -322,7 +345,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
transition={{ layout: layoutTransition }}
|
transition={{ layout: layoutTransition }}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn (
|
className={cn (
|
||||||
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
|
'sidebar-panel relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
|
||||||
className)}>
|
className)}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<style>
|
<style>
|
||||||
@@ -345,16 +368,16 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation={verticalPreview ? 'horizontal' : 'vertical'}
|
||||||
aria-label={message ('sidebarWidth').text}
|
aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text}
|
||||||
lang={message ('sidebarWidth').language}
|
lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerEnd}
|
onPointerUp={handlePointerEnd}
|
||||||
onPointerCancel={handlePointerEnd}
|
onPointerCancel={handlePointerEnd}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
className={cn (
|
className={cn (
|
||||||
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
|
'sidebar-resize hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
|
||||||
'touch-none select-none',
|
'touch-none select-none',
|
||||||
side === 'left' ? '-right-2' : '-left-2',
|
side === 'left' ? '-right-2' : '-left-2',
|
||||||
'before:absolute before:top-0 before:bottom-0 before:w-px',
|
'before:absolute before:top-0 before:bottom-0 before:w-px',
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import PostTagsField from '@/components/posts/PostTagsField'
|
import PostTagsField from '@/components/posts/PostTagsField'
|
||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
@@ -89,12 +88,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
|||||||
disabled={parentPostIds.disabled}
|
disabled={parentPostIds.disabled}
|
||||||
warnings={parentPostIds.warnings}
|
warnings={parentPostIds.warnings}
|
||||||
errors={parentPostIds.errors}
|
errors={parentPostIds.errors}
|
||||||
onChange={parentPostIds.onChange}
|
onChange={parentPostIds.onChange}/>
|
||||||
after={<ResourcePickerButton
|
|
||||||
source={{ resource: 'posts', field: 'resource_id' }}
|
|
||||||
values={parentPostIds.value.split (/\s+/).filter (Boolean)}
|
|
||||||
maximum={Number.MAX_SAFE_INTEGER} disabled={parentPostIds.disabled}
|
|
||||||
onSelect={values => parentPostIds.onChange (values.join (' '))}/>}/>
|
|
||||||
</>)
|
</>)
|
||||||
|
|
||||||
export default PostCoreDataFields
|
export default PostCoreDataFields
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ const PostTextField: FC<Props> = (
|
|||||||
<FormField label={label} messages={errors}>
|
<FormField label={label} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -46,10 +44,8 @@ const PostTextField: FC<Props> = (
|
|||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
aria-invalid={invalid}
|
aria-invalid={invalid}
|
||||||
className={inputClass (invalid, className)}/>
|
className={inputClass (invalid, className)}/>
|
||||||
</div>
|
|
||||||
{after}
|
|
||||||
</div>
|
|
||||||
<FieldWarning messages={warnings}/>
|
<FieldWarning messages={warnings}/>
|
||||||
|
{after}
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ const buttonVariants = cva (
|
|||||||
link:
|
link:
|
||||||
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
|
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: '[block-size:2.5rem] [padding-inline:1rem] [padding-block:.5rem]',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: '[block-size:2.25rem] rounded-md [padding-inline:.75rem]',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: '[block-size:2.75rem] rounded-md [padding-inline:2rem]',
|
||||||
icon: 'h-10 w-10'}},
|
icon: '[block-size:2.5rem] [inline-size:2.5rem]'}},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: 'default',
|
||||||
size: 'default'}})
|
size: 'default'}})
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { API_BASE_URL } from '@/config'
|
||||||
|
|
||||||
|
describe ('test API configuration', () => {
|
||||||
|
it ('resolves the sample configuration without a local config file', () => {
|
||||||
|
expect (API_BASE_URL).toBe ('/api')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,7 +9,8 @@ import { createStaticHandler, createStaticRouter,
|
|||||||
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
|
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
|
||||||
import { apiGet, isApiError } from '@/lib/api'
|
import { apiGet, isApiError } from '@/lib/api'
|
||||||
import { createUII18n } from '@/lib/i18n'
|
import { createUII18n } from '@/lib/i18n'
|
||||||
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
|
import { fetchLocales, isLocaleCode, isVerticalPreviewPath,
|
||||||
|
legacyLocalePath, uiLocaleFromPath } from '@/lib/locales'
|
||||||
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
import { withRenderContext } from '@/lib/renderContext.server'
|
import { withRenderContext } from '@/lib/renderContext.server'
|
||||||
|
|
||||||
@@ -72,13 +73,16 @@ export const render = async (
|
|||||||
pathname => resolvePageRoute (pathname) != null)
|
pathname => resolvePageRoute (pathname) != null)
|
||||||
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
|
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
|
||||||
const segment = pathname.split ('/')[1]
|
const segment = pathname.split ('/')[1]
|
||||||
|
const verticalPreview = isVerticalPreviewPath (pathname)
|
||||||
const known = resolvePageRoute (pathname)
|
const known = resolvePageRoute (pathname)
|
||||||
if (legacy != null && known != null)
|
if (legacy != null && known != null)
|
||||||
return { status: 308, location: legacy }
|
return { status: 308, location: legacy }
|
||||||
const locale = isLocaleCode (segment) ? segment : 'ja'
|
const locale = isLocaleCode (segment) ? segment : 'ja'
|
||||||
|
const uiLocale = uiLocaleFromPath (pathname)
|
||||||
|
const routePrefix = verticalPreview ? 'mn-Mong' : locale
|
||||||
if (known?.route.handle?.redirectTo != null)
|
if (known?.route.handle?.redirectTo != null)
|
||||||
return { status: 308,
|
return { status: 308,
|
||||||
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
|
location: `/${routePrefix}${known.route.handle.redirectTo}${url.search}` }
|
||||||
const requestContext: RenderContext & { status: number } = {
|
const requestContext: RenderContext & { status: number } = {
|
||||||
pathname: url.pathname, origin, apiBaseURL,
|
pathname: url.pathname, origin, apiBaseURL,
|
||||||
...authentication, status: 200 }
|
...authentication, status: 200 }
|
||||||
@@ -106,14 +110,15 @@ export const render = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const prepare = known?.route.handle?.prepare
|
const prepare = known?.route.handle?.prepare
|
||||||
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
|
const routeLocaleValid = isLocaleCode (segment) || verticalPreview
|
||||||
|
let status = known == null || !active || !routeLocaleValid ? 404 : 200
|
||||||
if (status === 200 && known?.route.handle?.authentication != null
|
if (status === 200 && known?.route.handle?.authentication != null
|
||||||
&& client.getQueryData (sessionKeys.current) == null)
|
&& client.getQueryData (sessionKeys.current) == null)
|
||||||
status = 401
|
status = 401
|
||||||
if (prepare != null && known != null && status === 200)
|
if (prepare != null && known != null && status === 200)
|
||||||
status = await prepare ({ client, params: known.params, locale,
|
status = await prepare ({ client, params: known.params, locale,
|
||||||
searchParams: url.searchParams })
|
searchParams: url.searchParams })
|
||||||
const instance = createUII18n (locale)
|
const instance = createUII18n (uiLocale)
|
||||||
const handler = createStaticHandler (appRoutes)
|
const handler = createStaticHandler (appRoutes)
|
||||||
const context = await handler.query (new Request (url))
|
const context = await handler.query (new Request (url))
|
||||||
if (context instanceof Response)
|
if (context instanceof Response)
|
||||||
@@ -183,11 +188,11 @@ export const render = async (
|
|||||||
isAxiosError: true, response: {
|
isAxiosError: true, response: {
|
||||||
status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
|
status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
|
||||||
})) }
|
})) }
|
||||||
const bootstrap = JSON.stringify ({ state, status, locale,
|
const bootstrap = JSON.stringify ({ state, status, locale: uiLocale,
|
||||||
url: url.pathname + url.search })
|
url: url.pathname + url.search })
|
||||||
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
||||||
.replace (/\u2029/g, '\\u2029')
|
.replace (/\u2029/g, '\\u2029')
|
||||||
return { status, html, head, locale, bootstrap,
|
return { status, html, head, locale: uiLocale, bootstrap,
|
||||||
setCookies: requestContext.setCookies }
|
setCookies: requestContext.setCookies }
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -625,3 +625,642 @@ body
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Locally hosted Mongolian font; the OFL accompanies the font file. */
|
||||||
|
@font-face
|
||||||
|
{
|
||||||
|
font-family: 'BTRC Mongolian';
|
||||||
|
src: url('/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf') format('truetype');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mongolian flows down each line, then into the next column on the right. */
|
||||||
|
:lang(mn-Mong)
|
||||||
|
{
|
||||||
|
font-family: 'BTRC Mongolian', 'Mongolian Baiti', 'Noto Sans JP Variable', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Include portalled dialogues and toasts, and controls with UA writing modes. */
|
||||||
|
html:lang(mn-Mong) body > :not(#root),
|
||||||
|
html:lang(mn-Mong) #root > :not(.app-layout),
|
||||||
|
html:lang(mn-Mong) :is(button, input, select, textarea),
|
||||||
|
[data-vertical-preview] :is(
|
||||||
|
.main-area:not(.posts-main-area, .tags-main-area, .wiki-main-area),
|
||||||
|
.sidebar > *, .tag-list-filter, .post-list > *,
|
||||||
|
.top-nav-brand-link, .top-nav-menu-link,
|
||||||
|
.top-nav-user-desktop, .top-nav-user-mobile, .top-nav-mobile-menu)
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing Japanese vertical decorations must also advance to the right. */
|
||||||
|
html:lang(mn-Mong) [style*='writing-mode']
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline alignment turns left/right into top/bottom; flex cross alignment
|
||||||
|
and table vertical-align already follow the vertical writing mode. */
|
||||||
|
html:lang(mn-Mong) .text-left
|
||||||
|
{
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:lang(mn-Mong) .text-right
|
||||||
|
{
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview]
|
||||||
|
{
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] > :not(.top-nav-layout)
|
||||||
|
{
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] [data-sidebar-container]
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area
|
||||||
|
{
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content]
|
||||||
|
{
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specialised lists below retain their own grid and table layouts. */
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content]:not(
|
||||||
|
.posts-main-area, .tags-main-area, .wiki-main-area)
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
text-align: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content]:not(
|
||||||
|
.posts-main-area, .tags-main-area, .wiki-main-area) > :is(div, article, section)
|
||||||
|
{
|
||||||
|
max-width: none;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] .prose
|
||||||
|
{
|
||||||
|
max-width: none;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input and data widgets retain nested scrolling in the vertical text flow. */
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] :is(
|
||||||
|
form, .form-layout, pre, .overflow-x-auto, .rc-md-editor, .not-prose)
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] :is(form, .form-layout)
|
||||||
|
{
|
||||||
|
width: auto;
|
||||||
|
inline-size: min(36rem, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] :where(.main-area[data-vertical-content]) :is(
|
||||||
|
button, input, select, textarea, code, time, table)
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] :is(img, video, iframe, canvas)
|
||||||
|
{
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar-panel
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
height: var(--sidebar-height);
|
||||||
|
min-height: 0;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
direction: ltr;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar > *
|
||||||
|
{
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar > form
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar > form > input[type='text']
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: .75rem .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar > form > .tag-search-suggestions
|
||||||
|
{
|
||||||
|
inset: auto;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
inset-block-start: 100%;
|
||||||
|
width: auto;
|
||||||
|
inline-size: 100%;
|
||||||
|
max-block-size: min(16rem, 75vw);
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar > div
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar :is(h2, h3, ul)
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
margin-block-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar-resize
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
inset: auto 0 -.5rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: row-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .sidebar-resize::before
|
||||||
|
{
|
||||||
|
inset: 50% 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-locale-name:has(:lang(mn-Mong))
|
||||||
|
{
|
||||||
|
display: inline;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-menu-link:has(.top-nav-locale-name)
|
||||||
|
{
|
||||||
|
height: auto;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The post grid fills each column downwards before continuing to the right. */
|
||||||
|
[data-vertical-preview] .posts-main-area > div
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
width: max-content;
|
||||||
|
margin: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .posts-main-area > div > div:first-child
|
||||||
|
{
|
||||||
|
flex: none;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .posts-main-area > div > div:last-child
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] .post-list
|
||||||
|
{
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 10rem;
|
||||||
|
grid-template-rows: repeat(auto-fill, min(10rem, 100%));
|
||||||
|
flex: none;
|
||||||
|
width: max-content;
|
||||||
|
height: 100%;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] .post-list > a
|
||||||
|
{
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] nav
|
||||||
|
{
|
||||||
|
flex: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
margin: 0;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] .pagination
|
||||||
|
{
|
||||||
|
align-self: stretch;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tags-main-area
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-filter
|
||||||
|
{
|
||||||
|
flex: none;
|
||||||
|
width: auto;
|
||||||
|
inline-size: min(24rem, 100%);
|
||||||
|
max-inline-size: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-filter h1
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
max-height: 8rem;
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-filter input[type='number']
|
||||||
|
{
|
||||||
|
max-inline-size: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-results
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-table
|
||||||
|
{
|
||||||
|
max-width: none;
|
||||||
|
overflow-x: visible;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-table table
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
inline-size: max-content;
|
||||||
|
block-size: max-content;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-table col
|
||||||
|
{
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-table :is(th, td)
|
||||||
|
{
|
||||||
|
min-inline-size: 2rem;
|
||||||
|
max-inline-size: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-table thead
|
||||||
|
{
|
||||||
|
border-bottom: 0;
|
||||||
|
border-block-end: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .tag-list-table td:nth-child(2),
|
||||||
|
[data-vertical-preview] .tag-list-table time
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee,
|
||||||
|
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__static
|
||||||
|
{
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
max-inline-size: 16rem;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__animated
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prose flows through the viewport height and grows into columns on the right. */
|
||||||
|
[data-vertical-preview] .wiki-main-area
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area > div
|
||||||
|
{
|
||||||
|
flex: none;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area > div > span
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article,
|
||||||
|
[data-vertical-preview] .posts-main-area .localised-document
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: max-content;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] .localised-document
|
||||||
|
{
|
||||||
|
max-width: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article :is(h1, h2, h3, h4, p, ul, ol, blockquote)
|
||||||
|
{
|
||||||
|
margin: 0;
|
||||||
|
margin-block: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article :is(ul, ol)
|
||||||
|
{
|
||||||
|
padding: 0;
|
||||||
|
padding-inline-start: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article :is(img, video, iframe, svg)
|
||||||
|
{
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose),
|
||||||
|
[data-vertical-preview] .main-area[data-vertical-content] :is(code, time, input, select, textarea)
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose)
|
||||||
|
{
|
||||||
|
display: inline-block;
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: min(48rem, 80vw);
|
||||||
|
overflow: auto;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area article .not-prose
|
||||||
|
{
|
||||||
|
width: max-content;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area .not-prose > div
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .wiki-main-area .not-prose > div > div:last-child
|
||||||
|
{
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px)
|
||||||
|
{
|
||||||
|
[data-vertical-preview].app-layout
|
||||||
|
{
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-layout
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 50vw;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-bar
|
||||||
|
{
|
||||||
|
flex-direction: column;
|
||||||
|
flex: none;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 100%;
|
||||||
|
padding: .5rem;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-primary
|
||||||
|
{
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-brand-link
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
max-height: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-block: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-desktop-menu
|
||||||
|
{
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-desktop-menu > div:not(.top-nav-highlight)
|
||||||
|
{
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-desktop-menu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
height: auto;
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-menu-link > :not(svg, select),
|
||||||
|
[data-vertical-preview] .top-nav-more-menu h2
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
max-height: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-highlight
|
||||||
|
{
|
||||||
|
inset-inline-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-account
|
||||||
|
{
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-account .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-locale-name
|
||||||
|
{
|
||||||
|
display: inline;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-desktop-submenu
|
||||||
|
{
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-submenu-content
|
||||||
|
{
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
text-orientation: mixed;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 35vw;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-submenu-content:not(.top-nav-more-menu) > div
|
||||||
|
{
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] :is(.top-nav-submenu-items, .top-nav-more-links)
|
||||||
|
{
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
max-height: 100%;
|
||||||
|
padding-inline: .75rem;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-desktop-submenu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 40px;
|
||||||
|
padding-inline: .75rem;
|
||||||
|
padding-block: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-vertical-preview] .top-nav-submenu-items .top-nav-menu-link
|
||||||
|
{
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px)
|
||||||
|
{
|
||||||
|
[data-vertical-preview] .top-nav-layout
|
||||||
|
{
|
||||||
|
max-height: 50dvh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,19 +89,18 @@ export const fetchCountsDefinition = (
|
|||||||
.then (definition => localiseCountsDefinition (definition, locale))
|
.then (definition => localiseCountsDefinition (definition, locale))
|
||||||
|
|
||||||
export type CountsCandidate = {
|
export type CountsCandidate = {
|
||||||
value: string; id?: number | string; name: string | null; category?: string | null
|
value: string; id?: number; name: string | null; category?: string | null
|
||||||
names?: Record<string, unknown>
|
names?: Record<string, unknown>
|
||||||
kind?: 'internal' | 'external' | 'group' }
|
kind?: 'internal' | 'external' }
|
||||||
export type CountsCandidatesResult = {
|
export type CountsCandidatesResult = {
|
||||||
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
rows: CountsCandidate[]; page: number; more: boolean; executionAt: string }
|
||||||
|
|
||||||
export const fetchCountsCandidates = (
|
export const fetchCountsCandidates = (
|
||||||
resource: CountsResource,
|
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<CountsCandidatesResult> => apiGet (
|
): Promise<CountsCandidatesResult> => apiGet (
|
||||||
`/counts/definitions/${encodeURIComponent (resource)}/candidates`, {
|
'/counts/definitions/posts/candidates', {
|
||||||
params: Object.fromEntries (params), locale, signal })
|
params: Object.fromEntries (params), locale, signal })
|
||||||
|
|
||||||
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
|
export const countsSetOperators = ['in', 'not_in', 'any', 'all', 'none']
|
||||||
@@ -156,25 +155,3 @@ export const countsAxisFields = (
|
|||||||
history: boolean,
|
history: boolean,
|
||||||
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
): CountField[] => definition.axes[history ? 'history' : 'latest']
|
||||||
.map (key => ({ key, label: countsFieldLabel (key) }))
|
.map (key => ({ key, label: countsFieldLabel (key) }))
|
||||||
|
|
||||||
|
|
||||||
export const countsCandidateSource = (
|
|
||||||
resource: CountsResource,
|
|
||||||
field: string,
|
|
||||||
params: URLSearchParams,
|
|
||||||
): { resource: CountsResource; field: string; params: URLSearchParams } | undefined => {
|
|
||||||
if (['resource_id', 'user', 'updated_by_user_id', 'file_blob_id', 'source_file_id']
|
|
||||||
.includes (field)) return { resource, field, params }
|
|
||||||
if (field === 'user.id') return { resource, field: 'user', params }
|
|
||||||
if ((field === 'tags.id' && resource === 'posts')
|
|
||||||
|| (field === 'members' && resource === 'tag_groups'))
|
|
||||||
return { resource, field, params }
|
|
||||||
const leaf = field.split ('.').at (-1) ?? ''
|
|
||||||
const targets: Record<string, CountsResource> = {
|
|
||||||
tagGroupId: 'tag_groups', tagId: 'tags', tag_id: 'tags', parent_id: 'materials',
|
|
||||||
parents: resource === 'posts' ? 'posts' : 'tags',
|
|
||||||
'parents.id': resource === 'posts' ? 'posts' : 'tags',
|
|
||||||
parent_post_ids: 'posts', parent_tag_ids: 'tags' }
|
|
||||||
const target = targets[field] ?? targets[leaf]
|
|
||||||
return target == null ? undefined : { resource: target, field: 'resource_id', params }
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-4
@@ -1,9 +1,11 @@
|
|||||||
import { createInstance } from 'i18next'
|
import { createInstance } from 'i18next'
|
||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next'
|
||||||
|
|
||||||
|
import { MONGOLIAN_LOCALE, SUPPORTED_LOCALES } from '@/lib/locales'
|
||||||
|
|
||||||
import type { i18n as I18n, TOptions } from 'i18next'
|
import type { i18n as I18n, TOptions } from 'i18next'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { UILocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
// Keep Japanese placeholders editable without reporting them as completed translations.
|
// Keep Japanese placeholders editable without reporting them as completed translations.
|
||||||
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
|
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
|
||||||
@@ -34,9 +36,14 @@ for (const [path, dictionary] of Object.entries (dictionaries))
|
|||||||
const [, , locale, filename] = path.split ('/')
|
const [, , locale, filename] = path.split ('/')
|
||||||
resources[locale] ??= { }
|
resources[locale] ??= { }
|
||||||
resources[locale][filename.slice (0, -5)] =
|
resources[locale][filename.slice (0, -5)] =
|
||||||
locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
|
locale === 'ja' ? dictionary : translatedResource (dictionary)
|
||||||
}
|
}
|
||||||
const namespaces = Object.keys (resources.ja ?? { })
|
const namespaces = Object.keys (resources.ja ?? { })
|
||||||
|
// No Mongolian translations yet; preserve the existing fallback language metadata.
|
||||||
|
resources[MONGOLIAN_LOCALE.code] ??= { }
|
||||||
|
for (const namespace of namespaces)
|
||||||
|
resources[MONGOLIAN_LOCALE.code][namespace] ??=
|
||||||
|
translatedResource ({ _untranslated: ['*'] })
|
||||||
|
|
||||||
|
|
||||||
export const createUII18n = (locale: string): I18n => {
|
export const createUII18n = (locale: string): I18n => {
|
||||||
@@ -44,7 +51,7 @@ export const createUII18n = (locale: string): I18n => {
|
|||||||
void instance.init ({
|
void instance.init ({
|
||||||
lng: locale,
|
lng: locale,
|
||||||
fallbackLng: 'ja',
|
fallbackLng: 'ja',
|
||||||
supportedLngs: ['ja', 'ain-Kana'],
|
supportedLngs: [...SUPPORTED_LOCALES, MONGOLIAN_LOCALE.code],
|
||||||
load: 'currentOnly',
|
load: 'currentOnly',
|
||||||
initAsync: false,
|
initAsync: false,
|
||||||
defaultNS: 'navigation',
|
defaultNS: 'navigation',
|
||||||
@@ -122,7 +129,7 @@ export const languageUIMessage = (
|
|||||||
|
|
||||||
|
|
||||||
export const untranslatedKeys = (
|
export const untranslatedKeys = (
|
||||||
locale: LocaleCode,
|
locale: UILocaleCode,
|
||||||
namespace = 'navigation',
|
namespace = 'navigation',
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
|
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
|
|||||||
|
|
||||||
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
|
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
|
||||||
|
|
||||||
|
// This UI locale uses Japanese content until the API supports Mongolian.
|
||||||
|
export const MONGOLIAN_LOCALE = { code: 'mn-Mong', name: 'ᠮᠣᠩᠭᠣᠯ' } as const
|
||||||
|
export type UILocaleCode = LocaleCode | typeof MONGOLIAN_LOCALE.code
|
||||||
|
|
||||||
export type AvailableLocale = { code: string; name: string }
|
export type AvailableLocale = { code: string; name: string }
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +32,19 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Frontend layout selection must not change the API's content locale.
|
||||||
|
export const isVerticalPreviewPath = (pathname: string): boolean =>
|
||||||
|
/^\/mn-Mong(?:[/?#]|$)/.test (pathname)
|
||||||
|
|
||||||
|
|
||||||
|
export const isUILocaleCode = (value: unknown): value is UILocaleCode =>
|
||||||
|
value === MONGOLIAN_LOCALE.code || isLocaleCode (value)
|
||||||
|
|
||||||
|
|
||||||
|
export const uiLocaleFromPath = (pathname: string): UILocaleCode =>
|
||||||
|
isVerticalPreviewPath (pathname) ? MONGOLIAN_LOCALE.code : localeFromPath (pathname) ?? 'ja'
|
||||||
|
|
||||||
|
|
||||||
export const resolveViewingLocale = (
|
export const resolveViewingLocale = (
|
||||||
pathname: string,
|
pathname: string,
|
||||||
routeLocale?: unknown,
|
routeLocale?: unknown,
|
||||||
@@ -40,27 +57,29 @@ export const currentViewingLocale = (): LocaleCode =>
|
|||||||
|
|
||||||
|
|
||||||
export const unlocalisedPath = (pathname: string): string => {
|
export const unlocalisedPath = (pathname: string): string => {
|
||||||
const locale = localeFromPath (pathname)
|
const locale = isVerticalPreviewPath (pathname) ? 'mn-Mong' : localeFromPath (pathname)
|
||||||
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
|
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const localiseTo = (to: To, locale: LocaleCode): To => {
|
export const localiseTo = (to: To, locale: LocaleCode, viewingPath = ''): To => {
|
||||||
const pathname = typeof to === 'string' ? to : to.pathname
|
const pathname = typeof to === 'string' ? to : to.pathname
|
||||||
if (pathname == null
|
if (pathname == null
|
||||||
|| !(pathname.startsWith ('/'))
|
|| !(pathname.startsWith ('/'))
|
||||||
|| pathname.startsWith ('//')
|
|| pathname.startsWith ('//')
|
||||||
|| localeFromPath (pathname) != null)
|
|| localeFromPath (pathname) != null
|
||||||
|
|| isVerticalPreviewPath (pathname))
|
||||||
return to
|
return to
|
||||||
|
|
||||||
const localised = `/${ locale }${ pathname }`
|
const prefix = isVerticalPreviewPath (viewingPath) ? 'mn-Mong' : locale
|
||||||
|
const localised = `/${ prefix }${ pathname }`
|
||||||
return typeof to === 'string' ? localised : { ...to, pathname: localised }
|
return typeof to === 'string' ? localised : { ...to, pathname: localised }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const switchLocalePath = (
|
export const switchLocalePath = (
|
||||||
location: { pathname: string; search: string; hash: string },
|
location: { pathname: string; search: string; hash: string },
|
||||||
locale: LocaleCode,
|
locale: UILocaleCode,
|
||||||
): string =>
|
): string =>
|
||||||
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
|
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
|
||||||
|
|
||||||
@@ -73,7 +92,8 @@ export const legacyLocalePath = (
|
|||||||
location: { pathname: string; search: string; hash: string },
|
location: { pathname: string; search: string; hash: string },
|
||||||
isPage: (pathname: string) => boolean = () => false,
|
isPage: (pathname: string) => boolean = () => false,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
if (localeFromPath (location.pathname) != null || !isPage (location.pathname))
|
if (localeFromPath (location.pathname) != null
|
||||||
|
|| isVerticalPreviewPath (location.pathname) || !isPage (location.pathname))
|
||||||
return null
|
return null
|
||||||
return switchLocalePath (location, automaticallySelectedLocale ())
|
return switchLocalePath (location, automaticallySelectedLocale ())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
|
|
||||||
import type navigation from '@/locales/ja/navigation.json'
|
import type navigation from '@/locales/ja/navigation.json'
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { UILocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
export type UIMessageKey = keyof typeof navigation
|
export type UIMessageKey = keyof typeof navigation
|
||||||
|
|
||||||
|
|
||||||
export const uiMessage = (
|
export const uiMessage = (
|
||||||
locale: LocaleCode,
|
locale: UILocaleCode,
|
||||||
key: UIMessageKey,
|
key: UIMessageKey,
|
||||||
values: Record<string, string | number> = { },
|
values: Record<string, string | number> = { },
|
||||||
): string => translateUIMessage (key, 'navigation', locale, values).text
|
): string => translateUIMessage (key, 'navigation', locale, values).text
|
||||||
|
|
||||||
|
|
||||||
export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
|
export const uiMessageLanguage = (locale: UILocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
|
||||||
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
|
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
|
||||||
? 'ain'
|
? 'ain'
|
||||||
: 'ja'
|
: 'ja'
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ export const theatresKeys = {
|
|||||||
export const countsKeys = {
|
export const countsKeys = {
|
||||||
definition: (resource: string, locale: LocaleCode = 'ja') =>
|
definition: (resource: string, locale: LocaleCode = 'ja') =>
|
||||||
['counts', 'definition', resource, locale] as const,
|
['counts', 'definition', resource, locale] as const,
|
||||||
candidates: (resource: string, params: string, locale: LocaleCode) =>
|
candidates: (params: string, locale: LocaleCode) => ['counts', 'candidates', locale, params] as const,
|
||||||
['counts', 'candidates', resource, locale, params] as const,
|
|
||||||
index: (resource: string, params: string, locale: LocaleCode) =>
|
index: (resource: string, params: string, locale: LocaleCode) =>
|
||||||
['counts', resource, locale, params] as const }
|
['counts', resource, locale, params] as const }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { localiseTo, resolveViewingLocale } from '@/lib/locales'
|
import { isVerticalPreviewPath, localiseTo,
|
||||||
|
resolveViewingLocale, uiLocaleFromPath } from '@/lib/locales'
|
||||||
|
|
||||||
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
|
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode, UILocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
|
|
||||||
export const useLocale = (): LocaleCode => {
|
export const useLocale = (): LocaleCode => {
|
||||||
@@ -15,13 +16,22 @@ export const useLocale = (): LocaleCode => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useUILocale = (): UILocaleCode =>
|
||||||
|
uiLocaleFromPath (useLocation ().pathname)
|
||||||
|
|
||||||
|
|
||||||
export const useLocaleNavigate = (): NavigateFunction => {
|
export const useLocaleNavigate = (): NavigateFunction => {
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const { pathname } = useLocation ()
|
||||||
return useCallback ((to: To | number, options?: NavigateOptions) => {
|
return useCallback ((to: To | number, options?: NavigateOptions) => {
|
||||||
if (typeof to === 'number')
|
if (typeof to === 'number')
|
||||||
navigate (to)
|
navigate (to)
|
||||||
else
|
else
|
||||||
navigate (localiseTo (to, locale), options)
|
navigate (localiseTo (to, locale, pathname), options)
|
||||||
}, [locale, navigate])
|
}, [locale, navigate, pathname])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const useVerticalPreview = (): boolean =>
|
||||||
|
isVerticalPreviewPath (useLocation ().pathname)
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
import LocalisedText from '@/components/LocalisedText'
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import { translateUIMessage } from '@/lib/i18n'
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useUILocale } from '@/lib/useLocale'
|
||||||
|
|
||||||
|
|
||||||
export const useUITranslation = (namespace = 'common') => {
|
export const useUITranslation = (namespace = 'common') => {
|
||||||
const locale = useLocale ()
|
const locale = useUILocale ()
|
||||||
const { i18n } = useTranslation (namespace, { lng: locale })
|
const { i18n } = useTranslation (namespace, { lng: locale })
|
||||||
|
|
||||||
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
|
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const msToTime = (ms: number): string => {
|
|||||||
|
|
||||||
|
|
||||||
export const inputClass = (invalid?: boolean, className?: string): string =>
|
export const inputClass = (invalid?: boolean, className?: string): string =>
|
||||||
cn ('w-full rounded border p-2',
|
cn ('[inline-size:100%] rounded border p-2',
|
||||||
(invalid
|
(invalid
|
||||||
? ['border-red-500 bg-red-50 text-red-900',
|
? ['border-red-500 bg-red-50 text-red-900',
|
||||||
'placeholder:text-red-300',
|
'placeholder:text-red-300',
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ import { I18nextProvider } from 'react-i18next'
|
|||||||
import '@/index.css'
|
import '@/index.css'
|
||||||
import App, { preloadPageRoute, resolvePageRoute } from '@/App'
|
import App, { preloadPageRoute, resolvePageRoute } from '@/App'
|
||||||
import { createUII18n } from '@/lib/i18n'
|
import { createUII18n } from '@/lib/i18n'
|
||||||
import { currentViewingLocale } from '@/lib/locales'
|
import { currentViewingLocale, uiLocaleFromPath } from '@/lib/locales'
|
||||||
import { localesKeys } from '@/lib/queryKeys'
|
import { localesKeys } from '@/lib/queryKeys'
|
||||||
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
|
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ const client = new QueryClient ({
|
|||||||
|
|
||||||
|
|
||||||
const mount = (serverRendered: boolean) => {
|
const mount = (serverRendered: boolean) => {
|
||||||
const instance = createUII18n (currentViewingLocale ())
|
const instance = createUII18n (uiLocaleFromPath (window.location.pathname))
|
||||||
const app = (
|
const app = (
|
||||||
<HelmetProvider context={helmetContext}>
|
<HelmetProvider context={helmetContext}>
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
|
|||||||
@@ -359,7 +359,8 @@ const CountsResourceContent = (
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</SidebarComponent>
|
</SidebarComponent>
|
||||||
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
|
<MainArea animateLayout={false} verticalContent={false}
|
||||||
|
className="flex min-h-0 min-w-0 flex-col p-2">
|
||||||
<PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle>
|
<PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle>
|
||||||
<div ref={controlsArea} className="shrink-0"
|
<div ref={controlsArea} className="shrink-0"
|
||||||
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
@@ -97,19 +96,13 @@ const MaterialHistoryPage: FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<FormField label={label ('materialId')}>
|
<FormField label={label ('materialId')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={materialIdInput}
|
value={materialIdInput}
|
||||||
aria-label={message ('materialId').text}
|
aria-label={message ('materialId').text}
|
||||||
lang={message ('materialId').language}
|
lang={message ('materialId').language}
|
||||||
onChange={e => setMaterialIdInput (e.target.value)}
|
onChange={e => setMaterialIdInput (e.target.value)}
|
||||||
className={inputClass (invalid, 'min-w-0 flex-1')}/>
|
className={inputClass (invalid)}/>)}
|
||||||
<ResourcePickerButton
|
|
||||||
source={{ resource: 'materials', field: 'resource_id' }}
|
|
||||||
values={materialIdInput === '' ? [] : [materialIdInput]}
|
|
||||||
onSelect={values => setMaterialIdInput (values[0] ?? '')}/>
|
|
||||||
</div>)}
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={label ('tagName')}>
|
<FormField label={label ('tagName')}>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState } from 'react'
|
|||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import ResourcePickerButton from '@/components/common/ResourcePickerButton'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
@@ -127,17 +126,11 @@ const MaterialSyncSuppressionsPage: FC = () => {
|
|||||||
|
|
||||||
<FormField label={label ('suppressions.driveFileId')}>
|
<FormField label={label ('suppressions.driveFileId')}>
|
||||||
{({ invalid }) => (
|
{({ invalid }) => (
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={driveFileId}
|
value={driveFileId}
|
||||||
onChange={e => setDriveFileId (e.target.value)}
|
onChange={e => setDriveFileId (e.target.value)}
|
||||||
className={inputClass (invalid, 'min-w-0 flex-1')}/>
|
className={inputClass (invalid)}/>)}
|
||||||
<ResourcePickerButton
|
|
||||||
source={{ resource: 'materials', field: 'drive_file_id' }}
|
|
||||||
values={driveFileId === '' ? [] : [driveFileId]}
|
|
||||||
onSelect={values => setDriveFileId (values[0] ?? '')}/>
|
|
||||||
</div>)}
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label={label ('suppressions.reason')}>
|
<FormField label={label ('suppressions.reason')}>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
|
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
|
||||||
|
|
||||||
<MainArea className="relative">
|
<MainArea className="relative" verticalContent={false}>
|
||||||
{post
|
{post
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const PostListPage: FC = () => {
|
|||||||
JSON.stringify (statesToSave))
|
JSON.stringify (statesToSave))
|
||||||
}}/>
|
}}/>
|
||||||
|
|
||||||
<MainArea>
|
<MainArea className="posts-main-area" verticalContent>
|
||||||
<TabGroup
|
<TabGroup
|
||||||
rightAddon={
|
rightAddon={
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -220,10 +220,10 @@ const TagListPage: FC = () => {
|
|||||||
}), [page, totalPages, location.search]))
|
}), [page, totalPages, location.search]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea className="min-w-0">
|
<MainArea className="tags-main-area min-w-0" verticalContent>
|
||||||
<PageHead title={message ('title')}/>
|
<PageHead title={message ('title')}/>
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="tag-list-filter max-w-xl">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<PageTitle>{label ('title')}</PageTitle>
|
<PageTitle>{label ('title')}</PageTitle>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
@@ -358,8 +358,8 @@ const TagListPage: FC = () => {
|
|||||||
|
|
||||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
{loading ? label ('common:loading') : (results.length > 0 ? (
|
{loading ? label ('common:loading') : (results.length > 0 ? (
|
||||||
<div className="mt-4 min-w-0 max-w-full">
|
<div className="tag-list-results mt-4 min-w-0 max-w-full">
|
||||||
<div className="min-w-0 max-w-full overflow-x-auto">
|
<div className="tag-list-table min-w-0 max-w-full overflow-x-auto">
|
||||||
<table className="w-full min-w-[2000px] table-auto border-collapse">
|
<table className="w-full min-w-[2000px] table-auto border-collapse">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-72"/>
|
<col className="w-72"/>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea className="wiki-main-area" verticalContent>
|
||||||
<PageHead title={{
|
<PageHead title={{
|
||||||
text: `${displayTitle}${deprecated
|
text: `${displayTitle}${deprecated
|
||||||
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
|
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from 'node:path'
|
||||||
import { defineConfig, mergeConfig } from 'vitest/config'
|
import { defineConfig, mergeConfig } from 'vitest/config'
|
||||||
|
|
||||||
import viteConfig from './vite.config'
|
import viteConfig from './vite.config'
|
||||||
@@ -6,7 +7,9 @@ import viteConfig from './vite.config'
|
|||||||
export default mergeConfig (
|
export default mergeConfig (
|
||||||
viteConfig,
|
viteConfig,
|
||||||
defineConfig ({
|
defineConfig ({
|
||||||
test: { environment: 'jsdom',
|
test: { alias: {
|
||||||
|
'@/config': path.resolve (__dirname, './src/config.sample.ts') },
|
||||||
|
environment: 'jsdom',
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'],
|
exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'],
|
||||||
setupFiles: './src/test/setup.ts',
|
setupFiles: './src/test/setup.ts',
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする