feat(admin-ui): shadcn/ui + Tailwind v4 foundation + theme switch

This commit is contained in:
Александр Зимин
2026-05-10 21:46:47 +00:00
parent c11a70128c
commit 3bfa7cae17
10 changed files with 1448 additions and 26 deletions
+16 -1
View File
@@ -14,21 +14,36 @@
"@hookform/resolvers": "^3.9.1",
"@nstart/ui": "^0.1.3",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.86.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.0.2",
"leaflet": "^1.9.4",
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-i18next": "^17.0.2",
"react-leaflet": "^5.0.0",
"react-oidc-context": "^3.3.1"
"react-oidc-context": "^3.3.1",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0-beta.7",
+1035
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -1,10 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<!-- Background: rounded square в brand ultramarain. Tailwind rounded-sm = 2px → ratio 2/32 = 6.25% → r=2 для viewBox 32. -->
<rect width="32" height="32" rx="6" ry="6" fill="#120a8f"/>
<!-- 'O' — Ordinis. Stroke-only (open ring), match'ит open design system без heavy fills. -->
<circle cx="16" cy="16" r="7.5" fill="none" stroke="#ffffff" stroke-width="2.5"/>
<!-- Спутник: маленький точка на орбите вокруг 'O'. Связь с ДЗЗ-доменом. -->
<circle cx="25.5" cy="9.5" r="1.7" fill="#ffffff"/>
<!-- Orbit trace: тонкая дуга через спутник, opacity 0.35 чтобы не доминировала. -->
<path d="M 8 24 Q 16 30 26 22" fill="none" stroke="#ffffff" stroke-width="0.7" stroke-linecap="round" opacity="0.35"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="32" height="32">
<!-- Brand mark v2 (handoff redesign): rounded-square в navy/coffee tone +
gold знак из logo-ujcvk01-dark-gold.svg. Цвета matched к --color-navy
и --color-warn handoff palette — при 16×16 в browser tab остаётся
читаемым на light и dark backgrounds. -->
<rect width="200" height="200" rx="36" ry="36" fill="#3a2818"/>
<g fill="#d4a653" transform="matrix(.085 0 0 -.085 7.55 192.45)">
<path d="m950 1292v-512h65 65l2 266 3 265 183-327 183-328-3-60-3-59-175-108c-96-59-178-108-182-108-5-1-8 80-8 179v180h-65-65v-295c0-162 2-295 5-295 2 0 143 85 312 188l308 189 3 109 3 109-307 550c-168 303-310 554-315 560-5 5-9-193-9-503z"/>
<path d="m577 1238-308-552 3-110 3-109 308-189c169-103 310-188 312-188 3 0 4 132 3 293l-3 292-62 3-63 3v-181c0-99-3-180-7-180-5 0-87 49-183 108l-175 108-3 61-3 61 183 328 183 328 3-267 2-268 63 3 62 3 3 503c1 276-1 502-6 502-4 0-146-248-315-552z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,57 @@
import { useTranslation } from 'react-i18next'
import { Monitor, Moon, Sun } from 'lucide-react'
import { useTheme, type ThemePreference } from '@/stores/ThemeProvider'
import { cn } from '@/lib/utils'
/**
* Tri-state theme switch: Light / Dark / System.
*
* <p>Segmented control с тремя иконками. Активный — `--color-accent`
* background + `--color-on-accent` foreground. Click меняет `preference`
* в {@link ThemeProvider}, который пишет в localStorage и переключает
* `data-theme` на html.
*
* <p>System icon показывает «follow OS» — auto-update при OS theme change.
*/
export function ThemeSwitch() {
const { t } = useTranslation()
const { preference, setPreference } = useTheme()
const items: { id: ThemePreference; label: string; icon: typeof Sun }[] = [
{ id: 'light', label: t('theme.light'), icon: Sun },
{ id: 'dark', label: t('theme.dark'), icon: Moon },
{ id: 'system', label: t('theme.system'), icon: Monitor },
]
return (
<div
role="radiogroup"
aria-label={t('theme.label')}
className="inline-flex items-center rounded-md border border-line bg-surface p-0.5"
>
{items.map((item) => {
const Icon = item.icon
const active = preference === item.id
return (
<button
key={item.id}
type="button"
role="radio"
aria-checked={active}
aria-label={item.label}
title={item.label}
onClick={() => setPreference(item.id)}
className={cn(
'inline-flex h-6 w-6 items-center justify-center rounded transition-colors',
active
? 'bg-accent text-on-accent'
: 'text-mute hover:text-ink hover:bg-surface-2',
)}
>
<Icon size={13} strokeWidth={2.25} />
</button>
)
})}
</div>
)
}
+10
View File
@@ -13,6 +13,11 @@ i18n
'ru-RU': {
translation: {
'app.title': 'Ordinis',
'theme.label': 'Тема',
'theme.light': 'Светлая',
'theme.dark': 'Тёмная',
'theme.system': 'Системная',
'nav.docs': 'Руководство',
'updateBanner.message': 'Доступна новая версия НСИ ({{version}}). Обновите страницу, чтобы получить последние исправления.',
'updateBanner.reload': 'Обновить',
'updateBanner.docs': 'Что нового',
@@ -513,6 +518,11 @@ i18n
'en-US': {
translation: {
'app.title': 'Ordinis MDM',
'theme.label': 'Theme',
'theme.light': 'Light',
'theme.dark': 'Dark',
'theme.system': 'System',
'nav.docs': 'Docs',
'updateBanner.message': 'A new NSI version ({{version}}) is available. Reload to get the latest fixes.',
'updateBanner.reload': 'Reload',
'updateBanner.docs': "What's new",
+12
View File
@@ -0,0 +1,12 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
/**
* Tailwind class merger — combines clsx (conditional classes) + tailwind-merge
* (resolves conflicts: e.g. `px-2 px-4` → `px-4`).
*
* Standard shadcn/ui utility, used by every primitive variant.
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+12 -9
View File
@@ -8,6 +8,7 @@ import { routeTree } from './routeTree.gen'
import { oidcAuthConfig } from '@/auth/oidcConfig'
import { TokenSync } from '@/auth/TokenSync'
import { RequireAuth } from '@/auth/RequireAuth'
import { ThemeProvider } from '@/stores/ThemeProvider'
import './i18n'
import './styles.css'
@@ -29,15 +30,17 @@ createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider {...oidcAuthConfig}>
<TokenSync />
<NStartUiProvider>
<RequireAuth>
<QueryClientProvider client={queryClient}>
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
<RouterProvider router={router} />
</DatePickerProvider>
</QueryClientProvider>
</RequireAuth>
</NStartUiProvider>
<ThemeProvider>
<NStartUiProvider>
<RequireAuth>
<QueryClientProvider client={queryClient}>
<DatePickerProvider datePicker={DATE_PICKER_LOCALE_RU}>
<RouterProvider router={router} />
</DatePickerProvider>
</QueryClientProvider>
</RequireAuth>
</NStartUiProvider>
</ThemeProvider>
</AuthProvider>
</StrictMode>,
)
+13
View File
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { LanguageSwitch } from '@nstart/ui'
import { AuthBadge } from '@/auth/AuthBadge'
import { useCanMutate } from '@/auth/useCanMutate'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { UpdateBanner } from '@/components/version/UpdateBanner'
import { VersionBadge } from '@/components/version/VersionBadge'
@@ -46,6 +47,17 @@ function RootLayout() {
>
{t('nav.search')}
</Link>
{/* Документация внешняя страница (Docusaurus ordinis-docs
deployment монтируется на /docs ingress'ом). target=_blank
чтобы не терять контекст приложения. */}
<a
href="/docs/"
target="_blank"
rel="noreferrer noopener"
className="text-carbon hover:text-ultramarain"
>
{t('nav.docs')}
</a>
{canMutate && (
<>
<Link
@@ -88,6 +100,7 @@ function RootLayout() {
</nav>
<div className="ml-auto flex items-center gap-3">
<VersionBadge />
<ThemeSwitch />
<LanguageSwitch
value={i18n.language}
options={LANG_OPTIONS}
@@ -0,0 +1,101 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
/**
* Theme: 'light' (Earth) | 'dark' (Claude-warm) | 'system' (follow prefers-color-scheme).
*
* <p>Persist'ится в {@code localStorage[STORAGE_KEY]}. На mount читает saved
* preference или fallback'ает к 'system'. Меняет {@code data-theme} attr на
* {@code <html>} CSS custom properties в styles.css переключают палитру.
*
* <p>{@code system} mode если user ничего не выбрал, следим за prefers-color-scheme
* media query. matchMedia listener обновляет attr реально-time при OS theme change.
*/
const STORAGE_KEY = 'ord-theme'
export type ThemePreference = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
type ThemeContextValue = {
preference: ThemePreference
resolved: ResolvedTheme
setPreference: (next: ThemePreference) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function readSystemPreference(): ResolvedTheme {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function readSavedPreference(): ThemePreference {
if (typeof window === 'undefined') return 'system'
const raw = window.localStorage.getItem(STORAGE_KEY)
if (raw === 'light' || raw === 'dark' || raw === 'system') return raw
return 'system'
}
function applyToDom(theme: ResolvedTheme) {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', theme)
// shadcn convention — добавляем .dark class тоже на случай third-party
// компонентов которые ждут именно его. Custom variant в styles.css всё равно
// их связывает, но эта мера держит совместимость на корню.
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// <meta name="color-scheme"> чтобы system browser UI (scrollbars, autofill)
// был в гармонии с темой.
let meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]')
if (!meta) {
meta = document.createElement('meta')
meta.name = 'color-scheme'
document.head.appendChild(meta)
}
meta.content = theme
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setPreferenceState] = useState<ThemePreference>(() => readSavedPreference())
const [systemResolved, setSystemResolved] = useState<ResolvedTheme>(() => readSystemPreference())
// Слушаем OS theme change при mode='system'.
useEffect(() => {
if (typeof window === 'undefined') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => setSystemResolved(mq.matches ? 'dark' : 'light')
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const resolved: ResolvedTheme = preference === 'system' ? systemResolved : preference
// Применяем к DOM каждый раз когда resolved меняется.
useEffect(() => {
applyToDom(resolved)
}, [resolved])
const setPreference = (next: ThemePreference) => {
setPreferenceState(next)
if (typeof window !== 'undefined') {
window.localStorage.setItem(STORAGE_KEY, next)
}
}
return (
<ThemeContext.Provider value={{ preference, resolved, setPreference }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used inside <ThemeProvider>')
}
return ctx
}
+182 -7
View File
@@ -1,12 +1,187 @@
@import "tailwindcss";
@import "@nstart/ui/styles/fonts.css";
@import "@nstart/ui/styles/theme.css";
@import 'tailwindcss';
@import 'tw-animate-css';
@import '@nstart/ui/styles/fonts.css';
@import '@nstart/ui/styles/theme.css';
/* Без @source Tailwind v4 не сгенерирует CSS для классов внутри dist/@nstart/ui */
@source "../node_modules/@nstart/ui/dist";
@source '../node_modules/@nstart/ui/dist';
html,
body,
#root {
/* Google Fonts Inter / JetBrains Mono / Tektur (handoff spec).
* @nstart fonts.css дублируется по части семейств overlap безвреден,
* на финальной фазе миграции (когда удалим @nstart/ui) останется только это. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Tektur:wght@500;600;700&display=swap');
/* === Dark mode trigger ===
* shadcn использует .dark class по умолчанию. Handoff просит [data-theme="dark"]
* на <html>. Custom variant связывает оба пути компоненты с `dark:` modifier
* срабатывают при любом из условий. */
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
/* === DESIGN TOKENS — Light theme (Earth, default :root) === */
:root {
--color-ink: #1a1714;
--color-ink-2: #52483d;
--color-mute: #8c8276;
--color-bg: #fbf8ee;
--color-surface: #ffffff;
--color-surface-2: #f4eedc;
--color-line: #e3dccb;
--color-line-2: #efe9d8;
--color-navy: #3a2818;
--color-accent: #b05a2e;
--color-accent-bg: rgb(176 90 46 / 11%);
--color-on-accent: #ffffff;
--color-green: #4f7038;
--color-green-bg: #e4ead0;
--color-warn: #a8762a;
--color-warn-bg: #f6e8bc;
--color-pink: #a64636;
--color-pink-bg: #f4d9cd;
/* shadcn semantic tokens — re-mapped onto наши значения */
--background: var(--color-bg);
--foreground: var(--color-ink);
--card: var(--color-surface);
--card-foreground: var(--color-ink);
--popover: var(--color-surface);
--popover-foreground: var(--color-ink);
--primary: var(--color-navy);
--primary-foreground: var(--color-on-accent);
--secondary: var(--color-surface-2);
--secondary-foreground: var(--color-ink);
--muted: var(--color-surface-2);
--muted-foreground: var(--color-mute);
--accent: var(--color-accent);
--accent-foreground: var(--color-on-accent);
--destructive: var(--color-pink);
--destructive-foreground: var(--color-on-accent);
--border: var(--color-line);
--input: var(--color-line);
--ring: var(--color-accent);
}
[data-theme='dark'] {
--color-ink: #f0ece4;
--color-ink-2: #c8c1b3;
--color-mute: #8a8474;
--color-bg: #1c1a17;
--color-surface: #262421;
--color-surface-2: #2e2b27;
--color-line: #3a3733;
--color-line-2: #2d2a27;
--color-navy: #d97757;
--color-accent: #d97757;
--color-accent-bg: rgb(217 119 87 / 16%);
--color-on-accent: #1c1a17;
--color-green: #6fa97d;
--color-green-bg: rgb(111 169 125 / 18%);
--color-warn: #d4a653;
--color-warn-bg: rgb(212 166 83 / 18%);
--color-pink: #d18272;
--color-pink-bg: rgb(209 130 114 / 20%);
--background: var(--color-bg);
--foreground: var(--color-ink);
--card: var(--color-surface);
--card-foreground: var(--color-ink);
--popover: var(--color-surface);
--popover-foreground: var(--color-ink);
--primary: var(--color-navy);
--primary-foreground: var(--color-on-accent);
--secondary: var(--color-surface-2);
--secondary-foreground: var(--color-ink);
--muted: var(--color-surface-2);
--muted-foreground: var(--color-mute);
--accent: var(--color-accent);
--accent-foreground: var(--color-on-accent);
--destructive: var(--color-pink);
--destructive-foreground: var(--color-on-accent);
--border: var(--color-line);
--input: var(--color-line);
--ring: var(--color-accent);
}
/* Tailwind v4 @theme inline экспонирует CSS vars как utility classes
* (`bg-bg`, `text-ink`, `border-line`, `bg-accent` и т.д.). */
@theme inline {
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-surface-2: var(--color-surface-2);
--color-ink: var(--color-ink);
--color-ink-2: var(--color-ink-2);
--color-mute: var(--color-mute);
--color-line: var(--color-line);
--color-line-2: var(--color-line-2);
--color-navy: var(--color-navy);
--color-accent: var(--color-accent);
--color-accent-bg: var(--color-accent-bg);
--color-on-accent: var(--color-on-accent);
--color-green: var(--color-green);
--color-green-bg: var(--color-green-bg);
--color-warn: var(--color-warn);
--color-warn-bg: var(--color-warn-bg);
--color-pink: var(--color-pink);
--color-pink-bg: var(--color-pink-bg);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', monospace;
--font-display: 'Tektur', 'Inter', sans-serif;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
}
html, body, #root {
height: 100%;
}
body {
background: var(--color-bg);
color: var(--color-ink);
font-family: var(--font-sans);
font-feature-settings: 'ss01', 'cv11', 'cv02', 'cv03', 'cv04';
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Tektur uppercase caption helper — handoff .cap class */
.cap {
font-family: var(--font-display);
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.10em;
color: var(--color-mute);
}
/* === Legacy fallback: старые компоненты используют nstart token names
* (--color-carbon, --color-ultramarain etc). Маппим их на новые pending
* полная миграция. === */
:root,
[data-theme='dark'] {
--color-carbon: var(--color-ink);
--color-ultramarain: var(--color-accent);
--color-orbit: var(--color-warn);
--color-regolith: var(--color-line);
--color-asteroid: var(--color-mute);
}