diff --git a/frontend/public/fonts/noto-sans-mongolian/LICENSE b/frontend/public/fonts/noto-sans-mongolian/LICENSE new file mode 100644 index 0000000..c82d72e --- /dev/null +++ b/frontend/public/fonts/noto-sans-mongolian/LICENSE @@ -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. diff --git a/frontend/public/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf b/frontend/public/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf new file mode 100644 index 0000000..2af5c0a Binary files /dev/null and b/frontend/public/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf differ diff --git a/frontend/public/fonts/noto-sans-mongolian/SOURCE.md b/frontend/public/fonts/noto-sans-mongolian/SOURCE.md new file mode 100644 index 0000000..844260e --- /dev/null +++ b/frontend/public/fonts/noto-sans-mongolian/SOURCE.md @@ -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 only by the Unicode Mongolian sample in the development layout route. +The font and license are served together from this directory. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a6799ad..dd76140 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster' import { isApiError } from '@/lib/api' import { initialiseUserSession } from '@/lib/users' import { prepareHelpPage } from '@/lib/help' -import { fetchLocales, isLocaleCode, legacyLocalePath, - switchLocalePath, unlocalisedPath } from '@/lib/locales' +import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath, + localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales' import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys' import { isCountsPath } from '@/lib/routeLocales' import { switchPostFilterLocale } from '@/lib/posts' import { switchWikiLocale } from '@/lib/wiki' -import { useLocale } from '@/lib/useLocale' +import { useLocale, useVerticalPreview } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, fetchUserThemeSlots, @@ -267,6 +267,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const client = useQueryClient () const location = useLocation () const locale = useLocale () + const verticalPreview = useVerticalPreview () const route = resolvePageRoute (location.pathname) const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots const pagePath = unlocalisedPath (location.pathname) @@ -393,7 +394,8 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { + data-vertical-preview={verticalPreview || undefined} + className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden"> { const LocaleRoute: FC = () => { const { locale } = useParams () const location = useLocation () + const verticalPreview = isVerticalPreviewPath (location.pathname) const { i18n } = useTranslation () const { data, isError } = useQuery ({ queryKey: localesKeys.index, @@ -421,10 +424,11 @@ const LocaleRoute: FC = () => { refetchOnMount: true, refetchOnWindowFocus: true, refetchOnReconnect: true }) + const contentLocale = verticalPreview ? 'ja' : locale const activeLocale = - (isLocaleCode (locale) - && data?.locales.some (entry => entry.code === locale)) - ? locale + (isLocaleCode (contentLocale) + && data?.locales.some (entry => entry.code === contentLocale)) + ? contentLocale : null useLayoutEffect (() => { @@ -435,15 +439,15 @@ const LocaleRoute: FC = () => { }, [activeLocale, i18n]) const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null) - if (!(isLocaleCode (locale)) && redirect == null) + if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null) return if (isError && data == null) return if (data == 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) ? : } @@ -455,7 +459,8 @@ const LocaleRoute: FC = () => { const LocaleRedirect = ({ to }: { to: string }) => { const locale = useLocale () const location = useLocation () - return + return } diff --git a/frontend/src/components/PostList.tsx b/frontend/src/components/PostList.tsx index 6e25110..e330736 100644 --- a/frontend/src/components/PostList.tsx +++ b/frontend/src/components/PostList.tsx @@ -34,7 +34,7 @@ const PostList: FC = ({ posts, onClick }) => { const cardRef = useRef (null) return ( -
+
{posts.map ((post, i) => { const sharedId = `page-${ post.id }` const layoutId = sharedId diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 27aefc7..1f050ff 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -1,9 +1,10 @@ import { useQueryClient } from '@tanstack/react-query' import { forwardRef, useMemo } from 'react' 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 { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' @@ -38,15 +39,18 @@ export default forwardRef (({ const navigate = useLocaleNavigate () const locale = useLocale () + const { pathname } = useLocation () const qc = useQueryClient () const behaviourSettings = useClientBehaviourSettings () const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const path = useMemo ( () => { - const localised = localiseTo (to, locale) - return typeof localised === 'string' ? localised : createPath (localised) + const localised = localiseTo (to, locale, pathname) + 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 (() => { return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString () diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 22dc2dd..4be2888 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -4,7 +4,7 @@ import { Globe } from 'lucide-react' import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useLocale, useLocaleNavigate } from '@/lib/useLocale' +import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale' import LocalisedText, { formatUIString } from '@/components/LocalisedText' import Separator from '@/components/MenuSeparator' import PrefetchLink from '@/components/PrefetchLink' @@ -137,6 +137,7 @@ export const menuOutline = ( const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { const { message, label } = useUITranslation ('shared') const locale = useLocale () + const verticalPreview = useVerticalPreview () const localeName = locales.find (entry => entry.code === locale)?.name const navigate = useLocaleNavigate () const location = useLocation () @@ -180,11 +181,12 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { let node: HTMLElement | null = el while (node != null && node !== nav) { - left += node.offsetLeft + left += verticalPreview ? node.offsetTop : node.offsetLeft const parent: Element | null = node.offsetParent 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 setHL (current => (current.left === left && current.width === width && current.visible === visible) @@ -202,6 +204,7 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { const [moreVsbl, setMoreVsbl] = useState (false) const [openItemIdx, setOpenItemIdx] = useState (-1) const [submenuHeight, setSubmenuHeight] = useState (0) + const [submenuWidth, setSubmenuWidth] = useState (0) const [wikiId, setWikiId] = useState (WikiIdBus.get ()) const wikiIdStr = String (wikiId ?? '') @@ -255,8 +258,22 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { animationsOff ? undefined : (reducedAnimations - ? 'transition-[transform,width] duration-75 ease-linear' - : 'transition-[transform,width] duration-200 ease-out') + ? 'transition-[transform,width,height] duration-75 ease-linear' + : '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 (activeIdx) @@ -287,6 +304,14 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { return } + if (verticalPreview) + { + setDesktop (true) + setDensity ('auto') + measure (moreVsbl ? -1 : activeIdx) + return + } + // Measure the same desktop elements while the mobile layout is active. layout.setAttribute ('data-measuring', '') const previousDensity = layout.getAttribute ('data-density') ?? 'auto' @@ -351,7 +376,8 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { removeEventListener ('resize', 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 (() => { if (desktop == null || layoutReady) @@ -378,7 +404,8 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { measure (moreVsbl ? -1 : activeIdx) if (!(desktop)) setMoreVsbl (false) - }, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels]) + }, [activeIdx, moreVsbl, desktop, density, locale, animationMode, + menuLabels, verticalPreview]) useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) @@ -392,6 +419,7 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { const update = () => { setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height)) + setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width)) } const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update) @@ -403,7 +431,7 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { observer?.disconnect () removeEventListener ('resize', update) } - }, [activeIdx, moreVsbl, desktop, density, locale]) + }, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview]) useEffect (() => { setMenuOpen (false) @@ -479,8 +507,11 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => { className={cn ('absolute inset-y-0 h-12', 'top-nav-highlight', highlightTransitionClass)} - style={{ width: hl.width, - transform: `translateX(${ hl.left }px)`, + style={{ width: verticalPreview ? '100%' : hl.width, + height: verticalPreview ? hl.width : undefined, + transform: verticalPreview + ? `translateY(${ hl.left }px)` + : `translateX(${ hl.left }px)`, opacity: hl.visible ? 1 : 0 }}/> {visibleMenu.map ((item, i) => ( @@ -529,10 +560,18 @@ const TopNav: FC = ({ user, locales, resolveLocaleSwitch }) => {
{localeSelector} + {verticalPreview && ( + + ᠮᠣᠩᠭᠣᠯ + )}
{menuOpen && localeSelector} + {verticalPreview && ( + + ᠮᠣᠩᠭᠣᠯ + )}