diff --git a/backend/app/controllers/post_versions_controller.rb b/backend/app/controllers/post_versions_controller.rb index 0ce1670..5d78f31 100644 --- a/backend/app/controllers/post_versions_controller.rb +++ b/backend/app/controllers/post_versions_controller.rb @@ -1,5 +1,6 @@ class PostVersionsController < ApplicationController def index + locale = resolve_locale! post_id = params[:post].presence tag_id = params[:tag].presence&.to_i external_tag_id = params[:external_tag].presence&.to_i @@ -45,12 +46,12 @@ class PostVersionsController < ApplicationController .limit(limit) .offset(offset) - render json: { versions: serialise_versions(versions), count: } + render json: { versions: serialise_versions(versions, locale.language_code), count: } end private - def serialise_versions rows + def serialise_versions rows, language_code rows = rows.to_a user_ids = rows.map(&:created_by_user_id).compact.uniq users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h @@ -58,12 +59,16 @@ class PostVersionsController < ApplicationController [normalise_json(row.tags_json), normalise_json(row.attributes['prev_tags_json']) || []] } + tag_ids = snapshots.flatten.filter_map { _1['tag_id'] }.uniq + tag_names = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true) + .pluck(:tag_id, :name).to_h external_tag_names = external_tag_names_for(snapshots) rows.map do |row| - cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names) + cur_tags = snapshot_tag_literals( + normalise_json(row.tags_json), tag_names, external_tag_names) prev_tags = snapshot_tag_literals( - normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names) + normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names) { post_id: row.post_id, version_no: row.version_no, @@ -106,17 +111,23 @@ class PostVersionsController < ApplicationController names end - def snapshot_tag_literals snapshots, external_tag_names + def snapshot_tag_literals snapshots, tag_names, external_tag_names snapshots .filter_map { |snapshot| if snapshot.key?('tag_id') + sections = snapshot.fetch('sections', []).map { |section| + [section.fetch('begin_ms'), section['end_ms']] + }.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] } + name = tag_names[snapshot['tag_id']] || snapshot.fetch('name') [tag_category_order(snapshot['category']), - Post.tag_snapshot_literal(snapshot)] + { identity: [:tag, snapshot['tag_id'], sections], + name: Post.tag_snapshot_literal(snapshot.merge('name' => name)) }] elsif external_tag_names[snapshot['external_tag_id']] - [6, external_tag_names[snapshot['external_tag_id']]] + [6, { identity: [:external_tag, snapshot['external_tag_id']], + name: external_tag_names[snapshot['external_tag_id']] }] end } - .sort_by { |order, name| [order, name.downcase] } + .sort_by { |order, tag| [order, tag[:name].downcase] } .map(&:second) end @@ -125,17 +136,20 @@ class PostVersionsController < ApplicationController end def build_version_tags cur_tags, prev_tags - (cur_tags | prev_tags).map do |name| + current = cur_tags.to_h { [_1[:identity], _1[:name]] } + previous = prev_tags.to_h { [_1[:identity], _1[:name]] } + + (current.keys | previous.keys).map do |identity| type = - if cur_tags.include?(name) && prev_tags.include?(name) + if current.key?(identity) && previous.key?(identity) 'context' - elsif cur_tags.include?(name) + elsif current.key?(identity) 'added' else 'removed' end - { name:, type: } + { name: current[identity] || previous[identity], type: } end end end diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7516be9..548c439 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,12 +25,14 @@ "clsx": "^2.1.1", "framer-motion": "^12.23.26", "humps": "^2.0.1", + "i18next": "^26.4.7", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", "path-to-regexp": "^8.3.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-helmet-async": "^2.0.5", + "react-i18next": "^17.0.20", "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", "react-router-dom": "^6.30.1", @@ -350,9 +352,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4872,6 +4874,15 @@ "node": ">=18" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4916,6 +4927,34 @@ "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", "license": "MIT" }, + "node_modules/i18next": { + "version": "26.4.7", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz", + "integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7258,6 +7297,33 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-i18next": { + "version": "17.0.20", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz", + "integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -8595,6 +8661,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 65b24e5..e5b2e34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,12 +29,14 @@ "clsx": "^2.1.1", "framer-motion": "^12.23.26", "humps": "^2.0.1", + "i18next": "^26.4.7", "lucide-react": "^0.511.0", "markdown-it": "^14.1.0", "path-to-regexp": "^8.3.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-helmet-async": "^2.0.5", + "react-i18next": "^17.0.20", "react-markdown": "^10.1.0", "react-markdown-editor-lite": "^1.3.4", "react-router-dom": "^6.30.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 948e4ac..b72f5b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' -import { Fragment, useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { createBrowserRouter, Navigate, Route, @@ -17,6 +18,7 @@ import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales' import { localesKeys } from '@/lib/queryKeys' +import { requiredPageLocale } from '@/lib/routeLocales' import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, @@ -41,7 +43,7 @@ import MorePage from '@/pages/MorePage' import GekanatorPage from '@/pages/GekanatorPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' -import TOSPage from '@/pages/TOSPage.mdx' +import TOSPage from '@/pages/TOSPage' import HelpDetailPage from '@/pages/help/HelpDetailPage' import HelpChangesPage from '@/pages/help/HelpChangesPage' import HelpDiffPage from '@/pages/help/HelpDiffPage' @@ -268,6 +270,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const LocaleRoute: FC = () => { const { locale } = useParams () const location = useLocation () + const { i18n } = useTranslation () const { data, isPending, isError, refetch } = useQuery ({ queryKey: localesKeys.index, queryFn: fetchLocales, @@ -275,6 +278,18 @@ const LocaleRoute: FC = () => { refetchOnMount: 'always', refetchOnWindowFocus: 'always', refetchOnReconnect: 'always' }) + const pageLocale = requiredPageLocale (location.pathname) + const activeLocale = + (isLocaleCode (locale) + && data?.locales.some (entry => entry.code === locale) + && (pageLocale == null || pageLocale === locale)) + ? locale + : null + + useLayoutEffect (() => { + if (activeLocale != null && i18n.language !== activeLocale) + void i18n.changeLanguage (activeLocale) + }, [activeLocale, i18n]) useEffect (() => { void refetch () @@ -287,6 +302,12 @@ const LocaleRoute: FC = () => { return if (isPending) return null + if (pageLocale != null && isLocaleCode (locale) && pageLocale !== locale) + { + return data.locales.some (entry => entry.code === pageLocale) + ? + : + } if (isLocaleCode (locale)) { return data.locales.some (entry => entry.code === locale) diff --git a/frontend/src/components/LocalisedText.tsx b/frontend/src/components/LocalisedText.tsx new file mode 100644 index 0000000..5ad3909 --- /dev/null +++ b/frontend/src/components/LocalisedText.tsx @@ -0,0 +1,53 @@ +import { Children, cloneElement, Fragment, isValidElement } from 'react' + +import { cn } from '@/lib/utils' + +import type { ReactNode } from 'react' + + +export const formatUIString = (text: string, language: string): string => + (language === 'ain' || language === 'ain-Kana') + ? text.replace (/[ \t\r\n]+/g, '\u3000') + : text + + +export const localiseUIChildren = (children: ReactNode, language: string): ReactNode => { + if (language !== 'ain' && language !== 'ain-Kana') + return children + + return Children.map (children, child => { + if (typeof child === 'string') + { + return formatUIString (child, language).split (/(\u3000+)/).map ((part, index) => + part.includes ('\u3000') + ? {part} + : {part}) + } + + if (isValidElement<{ children?: ReactNode; lang?: string }> (child)) + { + if (child.type === 'code' || child.type === 'pre' + || (child.props.lang != null && !(child.props.lang.startsWith ('ain')))) + return child + return cloneElement (child, { }, localiseUIChildren (child.props.children, language)) + } + + return child + }) +} + + +const LocalisedText = ( + { children, language, className }: { + children: ReactNode + language: string + className?: string }, +) => ( + + {localiseUIChildren (children, language)} + ) + + +export default LocalisedText diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 4afbb68..74f48c2 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -5,9 +5,11 @@ import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { useLocale, useLocaleNavigate } from '@/lib/useLocale' +import LocalisedText, { formatUIString } from '@/components/LocalisedText' import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' import TopNavUser from '@/components/TopNavUser' +import TranslatedText from '@/components/TranslatedText' import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' import { isLocaleCode, localeFromPath, @@ -41,7 +43,13 @@ export const menuOutline = ( ): Menu => { const pathName = unlocalisedPath (fullPathname) const locale = localeFromPath (fullPathname) ?? 'ja' - const message = (key: Parameters[1]) => uiMessage (locale, key) + + const message = ( + key: Parameters[1], + values?: Record, + ) => ({ name: uiMessage (locale, key, values), + nameLanguage: uiMessageLanguage (locale, key) }) + const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0 const editable = canEditContent (user) @@ -56,63 +64,63 @@ export const menuOutline = ( const helpIdentifier = pathName.split ('/')[2] ?? '' return [ - { name: message ('square'), to: '/posts', subMenu: [ - { name: message ('list'), to: '/posts' }, - { name: message ('search'), to: '/posts/search' }, - { name: message ('add'), to: '/posts/new', visible: editable }, - { name: message ('allHistory'), to: '/posts/changes' }, - { name: message ('help'), to: '/wiki/ヘルプ:広場' }] }, - { name: message ('tags'), to: '/tags', subMenu: [ - { name: message ('master'), to: '/tags' }, - { name: message ('nicoLinks'), to: '/tags/nico' }, - { name: message ('allHistory'), to: '/tags/changes' }, - { name: message ('help'), to: '/wiki/ヘルプ:タグ' }, + { ...message ('posts'), to: '/posts', subMenu: [ + { ...message ('list'), to: '/posts' }, + { ...message ('search'), to: '/posts/search' }, + { ...message ('add'), to: '/posts/new', visible: editable }, + { ...message ('allHistory'), to: '/posts/changes' }, + { ...message ('help'), to: '/wiki/ヘルプ:広場' }] }, + { ...message ('tags'), to: '/tags', subMenu: [ + { ...message ('master'), to: '/tags' }, + { ...message ('nicoLinks'), to: '/tags/nico' }, + { ...message ('allHistory'), to: '/tags/changes' }, + { ...message ('help'), to: '/wiki/ヘルプ:タグ' }, { component: , visible: tagFlg }, - { name: `広場 (${ postCount || 0 })`, + { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`, visible: tagFlg }, - { name: message ('history'), to: `/tags/changes?id=${ tag?.id }`, + { ...message ('history'), to: `/tags/changes?id=${ tag?.id }`, visible: tagFlg && tag?.category !== 'nico' }] }, - { name: message ('materials'), to: '/materials', visible: true, subMenu: [ - { name: message ('list'), to: '/materials' }, - { name: message ('add'), to: '/materials/new', visible: editable }, - { name: message ('suppressions'), to: '/materials/suppressions', + { ...message ('materials'), to: '/materials', visible: true, subMenu: [ + { ...message ('list'), to: '/materials' }, + { ...message ('add'), to: '/materials/new', visible: editable }, + { ...message ('suppressions'), to: '/materials/suppressions', visible: user?.role === 'admin' }, - { name: message ('allHistory'), to: '/materials/changes' }, - { name: message ('help'), to: '/wiki/ヘルプ:素材管理' }, + { ...message ('allHistory'), to: '/materials/changes' }, + { ...message ('help'), to: '/wiki/ヘルプ:素材管理' }, { component: , visible: materialFlg }, - { name: `広場 (${ postCount || 0 })`, + { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`, visible: materialFlg && Boolean (material?.tag) }, - { name: message ('history'), to: `/materials/changes?material_id=${ material?.id }`, + { ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`, visible: materialFlg }] }, - { name: message ('wiki'), to: '/wiki', subMenu: [ - { name: message ('search'), to: '/wiki' }, - { name: message ('create'), to: '/wiki/new', visible: editable }, - { name: message ('allHistory'), to: '/wiki/changes' }, - { name: message ('help'), to: '/wiki/ヘルプ:Wiki' }, + { ...message ('wiki'), to: '/wiki', subMenu: [ + { ...message ('search'), to: '/wiki' }, + { ...message ('create'), to: '/wiki/new', visible: editable }, + { ...message ('allHistory'), to: '/wiki/changes' }, + { ...message ('help'), to: '/wiki/ヘルプ:Wiki' }, { component: , visible: wikiPageFlg }, - { name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`, + { ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`, visible: wikiPageFlg }, - { name: message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, - { name: message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`, + { ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, + { ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg && editable }] }, - { name: message ('help'), to: '/help/home', base: '/help', subMenu: [ - { name: message ('search'), to: '/help' }, - { name: message ('allHistory'), to: '/help/changes' }, + { ...message ('help'), to: '/help/home', base: '/help', subMenu: [ + { ...message ('search'), to: '/help' }, + { ...message ('allHistory'), to: '/help/changes' }, { component: , visible: helpVsbl }, - { name: message ('history'), + { ...message ('history'), to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] }, - { name: message ('entertainment'), visible: false, subMenu: [ - { name: message ('theatre'), to: '/theatres/1' }, - { name: message ('gekanator'), to: '/gekanator' }] }, - { name: message ('users'), to: '/users/settings', visible: false, subMenu: [ - { name: message ('list'), to: '/users', visible: false }, - { name: message ('currentUser'), to: `/users/${ user?.id }`, visible: false }, - { name: message ('settings'), to: '/users/settings', visible: Boolean (user) }] }, - { name: message ('legal'), visible: false, subMenu: [ - { name: message ('terms'), to: '/tos' }] }] + { ...message ('entertainment'), visible: false, subMenu: [ + { ...message ('theatre'), to: '/theatres/1' }, + { ...message ('gekanator'), to: '/gekanator' }] }, + { ...message ('users'), to: '/users/settings', visible: false, subMenu: [ + { ...message ('list'), to: '/users', visible: false }, + { ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false }, + { ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] }, + { ...message ('legal'), visible: false, subMenu: [ + { ...message ('terms'), to: '/tos' }] }] } @@ -315,10 +323,10 @@ const TopNav: FC = ({ user, locales }) => { rounded px-1 focus-within:ring-2">